Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions inc/Workspace/WorkspaceWorktreeCleanupEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -1524,7 +1524,7 @@ private function revalidate_bounded_cleanup_eligible_candidate( array $candidate
'row_type' => 'protected',
'reason_code' => 'recent_activity',
'protecting_reason' => 'recent_activity',
'reason' => sprintf('worktree metadata was seen %d second(s) ago; retention cleanup requires at least %d second(s) without activity', (int) $recent_activity['age_seconds'], self::RETENTION_RECENT_ACTIVITY_SECONDS),
'reason' => sprintf('worktree lifecycle %s was updated %d second(s) ago; retention cleanup requires at least %d second(s) without activity', (string) $recent_activity['activity_field'], (int) $recent_activity['age_seconds'], self::RETENTION_RECENT_ACTIVITY_SECONDS),
'metadata' => $metadata,
),
$recent_activity
Expand Down Expand Up @@ -1795,19 +1795,29 @@ private function worktree_cleanup_has_removable_lifecycle( array $metadata ): bo
}

/**
* Return a recent-activity protection row when metadata heartbeat is fresh.
* Return a recent-activity protection row when lifecycle activity is fresh.
*
* Observation heartbeats are deliberately excluded: inventory and
* reconciliation scans refresh them without worktree activity.
*
* @param array<string,mixed> $metadata Worktree metadata.
* @return array<string,mixed>|null
*/
private function worktree_cleanup_recent_activity_protection( array $metadata ): ?array {
$last_seen = (string) ( $metadata['last_seen_at'] ?? $metadata['observed_at'] ?? '' );
if ( '' === $last_seen ) {
return null;
$activity_field = '';
$timestamp = 0;
foreach ( array( 'created_at', 'finalized_at', 'cleanup_eligible_at' ) as $field ) {
$value = (string) ( $metadata[ $field ] ?? '' );
$time = strtotime($value);
if ( '' === $value || false === $time || $time <= $timestamp ) {
continue;
}

$activity_field = $field;
$timestamp = $time;
}

$timestamp = strtotime($last_seen);
if ( false === $timestamp ) {
if ( '' === $activity_field ) {
return null;
}

Expand All @@ -1817,7 +1827,8 @@ private function worktree_cleanup_recent_activity_protection( array $metadata ):
}

return array(
'last_seen_at' => $last_seen,
'activity_at' => gmdate('c', $timestamp),
'activity_field' => $activity_field,
'age_seconds' => $age,
'recency_window_seconds' => self::RETENTION_RECENT_ACTIVITY_SECONDS,
);
Expand Down
9 changes: 8 additions & 1 deletion tests/worktree-retention-apply-protections.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,15 @@ private function git_get_remote( string $path ): ?string {

$recent_candidate = $base_candidate;
$recent_candidate['metadata']['last_seen_at'] = gmdate('c', time() - 60);
$recent_candidate['metadata']['observed_at'] = gmdate('c', time() - 60);
$recent = $harness->revalidate($recent_candidate);
retention_apply_protections_assert('recent_activity' === ( $recent['skipped']['reason_code'] ?? null ), 'recent last_seen_at rows are protected from apply removal');
retention_apply_protections_assert(! isset($recent['skipped']), 'recent observation heartbeats do not protect cleanup-eligible rows from apply removal');

$recent_lifecycle_candidate = $base_candidate;
$recent_lifecycle_candidate['metadata']['cleanup_eligible_at'] = gmdate('c', time() - 60);
$recent_lifecycle = $harness->revalidate($recent_lifecycle_candidate);
retention_apply_protections_assert('recent_activity' === ( $recent_lifecycle['skipped']['reason_code'] ?? null ), 'recent cleanup_eligible_at rows are protected from apply removal');
retention_apply_protections_assert('cleanup_eligible_at' === ( $recent_lifecycle['skipped']['activity_field'] ?? null ), 'recent lifecycle protection identifies the lifecycle activity field');

GitHubAbilities::$mode = 'open';
$open_pr = $harness->revalidate($base_candidate);
Expand Down
Loading