From da6af68aacb71bc913714001389e46e3d5f6f3e2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 25 Aug 2026 10:49:25 -0400 Subject: [PATCH] fix(workspace): preserve reused cleanup candidates --- .../WorkspaceWorktreeCleanupEngine.php | 67 +++++++++- inc/Workspace/WorkspaceWorktreeLifecycle.php | 4 +- .../WorktreeCleanupCandidateClassifier.php | 7 +- inc/Workspace/WorktreeContextInjector.php | 69 ++++++++++- .../bounded-cleanup-processed-candidates.php | 19 +++ tests/worktree-add-lifecycle.php | 24 +++- tests/worktree-cleanup-recovery-ref.php | 115 ++++++++++++++++++ .../worktree-retention-apply-protections.php | 37 +++++- 8 files changed, 323 insertions(+), 19 deletions(-) create mode 100644 tests/worktree-cleanup-recovery-ref.php diff --git a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php index 2fa20f6a..3dae9ab3 100644 --- a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php +++ b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php @@ -657,10 +657,16 @@ function () use ( $cand, $force, $remove_timeout_seconds ) { return $validated; } + $recovery = $this->preserve_cleanup_recovery_ref($validated); + if ( is_wp_error($recovery) ) { + return $recovery; + } + $remove = $this->remove_worktree_by_path($validated['repo'], $validated['branch'], $validated['path'], $force, $remove_timeout_seconds); if ( is_wp_error($remove) ) { return $remove; } + $remove = array_merge($remove, $recovery); if ( ! empty($validated['preserve_local_branch']) ) { $remove['local_branch_preserved'] = true; @@ -692,6 +698,10 @@ function () use ( $cand, $force, $remove_timeout_seconds ) { array( 'removed_path' => (string) ( $validated['path'] ?? '' ), 'path_exists_after' => is_dir( (string) ( $validated['path'] ?? '' ) ), + 'recovery_ref' => $remove['remove']['recovery_ref'] ?? null, + 'recovery_commit' => $remove['remove']['recovery_commit'] ?? null, + 'recovery_command' => $remove['remove']['recovery_command'] ?? null, + 'recovery_dispose_command' => $remove['remove']['recovery_dispose_command'] ?? null, ) ); ++$removed_count; @@ -1433,7 +1443,7 @@ public function worktree_bounded_cleanup_eligible_apply( array $opts = array() ) */ private function build_bounded_cleanup_processed_candidate( array $candidate, string $action, array $outcome ): array { $row = $candidate; - foreach ( array( 'dirty', 'unpushed', 'path', 'size_bytes', 'removal_status', 'removal_error', 'local_branch_deleted', 'branch_delete_error', 'path_exists_after' ) as $field ) { + foreach ( array( 'dirty', 'unpushed', 'path', 'size_bytes', 'removal_status', 'removal_error', 'local_branch_deleted', 'branch_delete_error', 'path_exists_after', 'recovery_ref', 'recovery_commit', 'recovery_command', 'recovery_dispose_command' ) as $field ) { if ( array_key_exists($field, $outcome) ) { $row[ $field ] = $outcome[ $field ]; } @@ -1499,6 +1509,10 @@ private function apply_worktree_cleanup_plan_candidates( array $candidates, bool 'removal_error' => $remove['removal_error'] ?? null, 'local_branch_deleted' => $remove['local_branch_deleted'] ?? null, 'branch_delete_error' => $remove['branch_delete_error'] ?? null, + 'recovery_ref' => $remove['recovery_ref'] ?? null, + 'recovery_commit' => $remove['recovery_commit'] ?? null, + 'recovery_command' => $remove['recovery_command'] ?? null, + 'recovery_dispose_command' => $remove['recovery_dispose_command'] ?? null, ) ); if ( null === $size ) { @@ -1572,6 +1586,11 @@ private function remove_revalidated_cleanup_candidate( array $candidate, bool $f $size = null === $measured ? null : (int) $measured; } + $recovery = ! empty($validated['broken_orphan']) ? array() : $this->preserve_cleanup_recovery_ref($validated); + if ( is_wp_error($recovery) ) { + return $recovery; + } + $result = $this->remove_worktree_by_path($repo, $branch, $wt_path, $force, $remove_timeout_seconds, ! empty($validated['broken_orphan'])); if ( is_wp_error($result) ) { return $result; @@ -1580,6 +1599,7 @@ private function remove_revalidated_cleanup_candidate( array $candidate, bool $f return $this->normalize_bounded_cleanup_locked_result($result, $validated); } + $result = array_merge($result, $recovery); $primary_path = $this->get_primary_path($repo); if ( '' !== $branch ) { $delete = $this->run_git($primary_path, sprintf('branch -D %s', escapeshellarg($branch)), self::CLEANUP_GIT_PROBE_TIMEOUT); @@ -1598,6 +1618,42 @@ private function remove_revalidated_cleanup_candidate( array $candidate, bool $f return array( 'validated' => $validated, 'size' => $size, 'remove' => $result ); } + /** Preserve the exact candidate commit before crossing the removal boundary. */ + private function preserve_cleanup_recovery_ref( array $candidate ): array|\WP_Error { + $repo = (string) ( $candidate['repo'] ?? '' ); + $worktree = (string) ( $candidate['path'] ?? '' ); + $primary_path = $this->get_primary_path($repo); + $head = $this->run_git($worktree, 'rev-parse --verify HEAD', self::CLEANUP_GIT_PROBE_TIMEOUT); + $commit = is_wp_error($head) ? '' : trim( (string) ( $head['output'] ?? '' ) ); + if ( 1 !== preg_match('/^[0-9a-f]{40,64}$/', $commit) ) { + return new \WP_Error('cleanup_recovery_head_unverified', 'Cleanup refused removal because the candidate commit could not be resolved for durable recovery.', array( 'status' => 409 )); + } + + $ref = 'refs/dmc/recovery/' . $commit; + $existing = $this->run_git($primary_path, sprintf('rev-parse --verify %s', escapeshellarg($ref)), self::CLEANUP_GIT_PROBE_TIMEOUT); + if ( ! is_wp_error($existing) && ! hash_equals($commit, trim( (string) ( $existing['output'] ?? '' ) )) ) { + return new \WP_Error('cleanup_recovery_ref_conflict', 'Cleanup refused removal because the deterministic recovery ref identifies another commit.', array( 'status' => 409, 'recovery_ref' => $ref )); + } + if ( is_wp_error($existing) ) { + $preserved = $this->run_git($primary_path, sprintf('update-ref %s %s %s', escapeshellarg($ref), escapeshellarg($commit), escapeshellarg(str_repeat('0', strlen($commit)))), self::CLEANUP_GIT_PROBE_TIMEOUT); + if ( is_wp_error($preserved) ) { + return new \WP_Error('cleanup_recovery_ref_failed', 'Cleanup refused removal because the durable recovery ref could not be written.', array( 'status' => 409, 'recovery_ref' => $ref )); + } + } + + $verified = $this->run_git($primary_path, sprintf('rev-parse --verify %s', escapeshellarg($ref)), self::CLEANUP_GIT_PROBE_TIMEOUT); + if ( is_wp_error($verified) || ! hash_equals($commit, trim( (string) ( $verified['output'] ?? '' ) )) ) { + return new \WP_Error('cleanup_recovery_ref_unverified', 'Cleanup refused removal because the durable recovery ref could not be verified.', array( 'status' => 409, 'recovery_ref' => $ref )); + } + + return array( + 'recovery_ref' => $ref, + 'recovery_commit' => $commit, + 'recovery_command' => sprintf('git -C %s worktree add --detach %s %s', escapeshellarg($primary_path), escapeshellarg($worktree), escapeshellarg($ref)), + 'recovery_dispose_command' => sprintf('git -C %s update-ref -d %s %s', escapeshellarg($primary_path), escapeshellarg($ref), escapeshellarg($commit)), + ); + } + /** Normalize the repository-lock callback contract before callers inspect it. */ private function normalize_bounded_cleanup_locked_result( mixed $result, array $candidate ): array|\WP_Error { if ( is_wp_error($result) ) { @@ -2044,11 +2100,10 @@ private function worktree_cleanup_removable_lifecycle_states(): array { * @return bool */ private function worktree_cleanup_has_removable_lifecycle( array $metadata ): bool { - $state = WorktreeContextInjector::project_lifecycle_state($metadata); - $finalized_state = isset($metadata['finalized_state']) ? WorktreeContextInjector::normalize_state( (string) $metadata['finalized_state']) : null; - $removable = $this->worktree_cleanup_removable_lifecycle_states(); + $state = WorktreeContextInjector::project_lifecycle_state($metadata); + $removable = $this->worktree_cleanup_removable_lifecycle_states(); - return in_array($state, $removable, true) || in_array($finalized_state, $removable, true); + return in_array($state, $removable, true); } /** @@ -2102,7 +2157,7 @@ private function worktree_cleanup_recent_activity_protection( array $metadata ): * @return bool */ private function worktree_cleanup_lifecycle_matches_reviewed_plan( array $reviewed_metadata, array $current_metadata ): bool { - foreach ( array( 'finalized_at', 'cleanup_eligible_at', 'created_at', 'lifecycle_state' ) as $field ) { + foreach ( array( 'finalized_at', 'cleanup_eligible_at', 'created_at', 'lifecycle_state', 'owner_run_ref', 'finalized_owner_run_ref', 'owner_terminal_at', 'owner_terminal_owner_run_ref' ) as $field ) { if ( (string) ( $reviewed_metadata[ $field ] ?? '' ) !== (string) ( $current_metadata[ $field ] ?? '' ) ) { return false; } diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 9a7d9269..2f771e5d 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -3088,7 +3088,7 @@ private function claim_expired_worktree( string $handle, string $branch, ?string 'new_purpose' => $intent['purpose'], 'base_ref' => $base, ); - $metadata = array_merge($metadata, array( + $metadata = WorktreeContextInjector::reactivate_for_reuse($metadata, array( 'lifecycle_state' => WorktreeContextInjector::STATE_ACTIVE, 'last_seen_at' => gmdate('c'), 'observed_at' => gmdate('c'), @@ -3098,7 +3098,7 @@ private function claim_expired_worktree( string $handle, string $branch, ?string 'ownership_lineage' => array_merge( (array) ( $metadata['ownership_lineage'] ?? array() ), array( $lineage )), )); $metadata['reuse_contract'] = array_merge($contract, $intent); - $stored = WorktreeContextInjector::store_lifecycle_metadata($handle, $metadata); + $stored = WorktreeContextInjector::restore_lifecycle_metadata($handle, $metadata); if ( is_wp_error($stored) ) { return new \WP_Error('worktree_claim_metadata_persistence_failed', 'Claim ownership metadata could not be persisted.', array( 'status' => 500, diff --git a/inc/Workspace/WorktreeCleanupCandidateClassifier.php b/inc/Workspace/WorktreeCleanupCandidateClassifier.php index 9af2ef6b..3d05ca17 100644 --- a/inc/Workspace/WorktreeCleanupCandidateClassifier.php +++ b/inc/Workspace/WorktreeCleanupCandidateClassifier.php @@ -240,16 +240,15 @@ private static function has_removable_lifecycle( mixed $metadata ): bool { return false; } - $state = isset($metadata['lifecycle_state']) ? WorktreeContextInjector::normalize_state( (string) $metadata['lifecycle_state']) : null; - $finalized_state = isset($metadata['finalized_state']) ? WorktreeContextInjector::normalize_state( (string) $metadata['finalized_state']) : null; - $removable = array( + $state = WorktreeContextInjector::project_lifecycle_state($metadata); + $removable = array( WorktreeContextInjector::STATE_CLEANUP_ELIGIBLE, WorktreeContextInjector::STATE_MERGED, WorktreeContextInjector::STATE_CLOSED, WorktreeContextInjector::STATE_ABANDONED, ); - return in_array($state, $removable, true) || in_array($finalized_state, $removable, true); + return in_array($state, $removable, true); } /** diff --git a/inc/Workspace/WorktreeContextInjector.php b/inc/Workspace/WorktreeContextInjector.php index a6344973..a3bda80b 100644 --- a/inc/Workspace/WorktreeContextInjector.php +++ b/inc/Workspace/WorktreeContextInjector.php @@ -301,7 +301,33 @@ public static function has_owner_terminal_disposable_cleanup_signal( array $meta return self::CLEANUP_POLICY_REMOVE_ON_SUCCESS === ( $metadata['cleanup_policy'] ?? null ) && null !== self::normalize_scalar_metadata_value($metadata['purpose'] ?? null) && null !== self::normalize_scalar_metadata_value($metadata['owner_run_ref'] ?? null) - && 'success' === ( $metadata['owner_terminal_outcome'] ?? null ); + && 'success' === ( $metadata['owner_terminal_outcome'] ?? null ) + && self::terminal_evidence_matches_current_ownership($metadata, 'owner_terminal_at', 'owner_terminal_owner_run_ref'); + } + + /** Remove terminal authority when a clean worktree starts a new lifecycle. */ + public static function reactivate_for_reuse( array $metadata, array $active_metadata ): array { + foreach ( array( + 'finalized_at', + 'finalized_state', + 'finalized_owner_run_ref', + 'cleanup_eligible_at', + 'owner_terminal_outcome', + 'owner_terminal_at', + 'owner_terminal_owner_run_ref', + 'auto_finalized_by', + 'auto_finalized_signal', + 'auto_finalized_reason', + 'cleanup_eligibility_evidence', + 'pr_ref', + 'pr_url', + 'pr_number', + 'pr_repo', + ) as $field ) { + unset($metadata[ $field ]); + } + + return array_merge($metadata, $active_metadata); } private static function optional_intent_value( mixed $value ): ?string { @@ -946,6 +972,10 @@ public static function build_finalizer_metadata( string $state, ?string $pr = nu 'lifecycle_state' => $normalized, 'finalized_at' => gmdate('c'), ); + $finalized_owner_run_ref = self::normalize_scalar_metadata_value($existing['owner_run_ref'] ?? null); + if ( null !== $finalized_owner_run_ref ) { + $metadata['finalized_owner_run_ref'] = $finalized_owner_run_ref; + } $pr_metadata = self::parse_pr_reference($pr); if ( ! empty($pr_metadata) ) { @@ -956,6 +986,9 @@ public static function build_finalizer_metadata( string $state, ?string $pr = nu if ( '' !== $owner_terminal_outcome ) { $metadata['owner_terminal_outcome'] = $owner_terminal_outcome; $metadata['owner_terminal_at'] = $metadata['finalized_at']; + if ( null !== $finalized_owner_run_ref ) { + $metadata['owner_terminal_owner_run_ref'] = $finalized_owner_run_ref; + } } if ( self::should_mark_cleanup_eligible($normalized, $pr_metadata) || self::has_owner_terminal_disposable_cleanup_signal(array_merge($existing, $metadata)) ) { @@ -1003,7 +1036,9 @@ public static function has_cleanup_signal( array $metadata ): bool { } $finalized_state = isset($metadata['finalized_state']) ? self::normalize_state( (string) $metadata['finalized_state']) : null; - return null !== $finalized_state && self::should_mark_cleanup_eligible($finalized_state, self::extract_pr_metadata($metadata)); + return null !== $finalized_state + && self::terminal_evidence_matches_current_ownership($metadata, 'finalized_at', 'finalized_owner_run_ref') + && self::should_mark_cleanup_eligible($finalized_state, self::extract_pr_metadata($metadata)); } /** @@ -1182,6 +1217,9 @@ public static function has_explicit_cleanup_eligibility( array $metadata ): bool if ( empty($metadata['cleanup_eligible_at']) || false === strtotime( (string) $metadata['cleanup_eligible_at'] ) || empty($metadata['finalized_at']) || false === strtotime( (string) $metadata['finalized_at'] ) ) { return false; } + if ( ! self::terminal_evidence_matches_current_ownership($metadata, 'finalized_at', 'finalized_owner_run_ref') ) { + return false; + } $finalized_state = isset($metadata['finalized_state']) ? self::normalize_state( (string) $metadata['finalized_state'] ) : null; if ( null !== $finalized_state && in_array($finalized_state, array( self::STATE_MERGED, self::STATE_CLOSED, self::STATE_ABANDONED, self::STATE_CLEANUP_ELIGIBLE ), true) ) { @@ -1191,6 +1229,33 @@ public static function has_explicit_cleanup_eligibility( array $metadata ): bool return array() !== self::extract_pr_metadata($metadata); } + /** Reject terminal evidence recorded by an owner superseded by a later claim. */ + private static function terminal_evidence_matches_current_ownership( array $metadata, string $timestamp_field, string $owner_field ): bool { + $terminal_owner = self::normalize_scalar_metadata_value($metadata[ $owner_field ] ?? null); + $current_owner = self::normalize_scalar_metadata_value($metadata['owner_run_ref'] ?? null); + if ( null !== $terminal_owner && $terminal_owner !== $current_owner ) { + return false; + } + + $latest_claim = 0; + foreach ( (array) ( $metadata['ownership_lineage'] ?? array() ) as $transition ) { + if ( ! is_array($transition) || ! array_key_exists('claimed_at', $transition) ) { + continue; + } + $claimed_at = is_scalar($transition['claimed_at']) ? strtotime((string) $transition['claimed_at']) : false; + if ( false === $claimed_at ) { + return false; + } + $latest_claim = max($latest_claim, $claimed_at); + } + if ( 0 === $latest_claim ) { + return true; + } + + $terminal_at = is_scalar($metadata[ $timestamp_field ] ?? null) ? strtotime((string) $metadata[ $timestamp_field ]) : false; + return false !== $terminal_at && $terminal_at >= $latest_claim; + } + /** * Extract PR-like fields from a persisted metadata record. * diff --git a/tests/bounded-cleanup-processed-candidates.php b/tests/bounded-cleanup-processed-candidates.php index f41bfb83..ccafd3b5 100644 --- a/tests/bounded-cleanup-processed-candidates.php +++ b/tests/bounded-cleanup-processed-candidates.php @@ -46,6 +46,7 @@ public function processed( array $candidate, string $action, array $outcome ): a final class BoundedCleanupRemovalCallbackHarness { use WorkspaceWorktreeCleanupEngine; + private const RECOVERY_COMMIT = '0123456789abcdef0123456789abcdef01234567'; protected const CLEANUP_GIT_PROBE_TIMEOUT = 5; protected const CLEANUP_GIT_REMOVE_TIMEOUT = 60; @@ -54,6 +55,7 @@ final class BoundedCleanupRemovalCallbackHarness { 'success' => true, 'removal_status' => 'complete', ); + public bool $fail_recovery_write = false; public function remove( array $candidate ): array|WP_Error { return $this->remove_revalidated_cleanup_candidate($candidate, false, false, 60, false); @@ -77,6 +79,15 @@ private function get_primary_path( string $repo ): string { } private function run_git( string $path, string $command, int $timeout = 0 ): array|WP_Error { + if ( str_starts_with($command, 'rev-parse --verify') ) { + if ( $this->fail_recovery_write && str_contains($command, 'refs/dmc/recovery/') ) { + return new WP_Error('missing_ref', 'recovery ref does not exist'); + } + return array( 'output' => self::RECOVERY_COMMIT ); + } + if ( str_starts_with($command, 'update-ref ') ) { + return $this->fail_recovery_write ? new WP_Error('cannot_write_ref', 'cannot write recovery ref') : array( 'output' => '' ); + } return new WP_Error('git_failed', 'cannot lock ref'); } } @@ -116,7 +127,12 @@ private function run_git( string $path, string $command, int $timeout = 0 ): arr $callback_harness = new BoundedCleanupRemovalCallbackHarness(); $callback_path = sys_get_temp_dir() . '/dmc-bounded-cleanup-callback-' . getmypid(); $callback_candidate = array_merge($candidate, array( 'path' => $callback_path )); +$callback_harness->fail_recovery_write = true; mkdir($callback_path); +$unpreserved = $callback_harness->remove($callback_candidate); +bounded_cleanup_processed_candidates_assert_same('cleanup_recovery_ref_failed', is_wp_error($unpreserved) ? $unpreserved->get_error_code() : null, 'cleanup fails closed when durable recovery cannot be written'); +bounded_cleanup_processed_candidates_assert_same(true, is_dir($callback_path), 'failed recovery preservation crossed the worktree removal boundary'); +$callback_harness->fail_recovery_write = false; $locked = $callback_harness->remove($callback_candidate); $branch_delete_error = array( 'code' => 'git_failed', @@ -126,12 +142,15 @@ private function run_git( string $path, string $command, int $timeout = 0 ): arr bounded_cleanup_processed_candidates_assert_same($callback_candidate, $locked['validated'] ?? null, 'branch deletion failure retains the normalized callback envelope'); bounded_cleanup_processed_candidates_assert_same(false, $locked['remove']['local_branch_deleted'] ?? null, 'normalized removal records retained local branch'); bounded_cleanup_processed_candidates_assert_same($branch_delete_error, $locked['remove']['branch_delete_error'] ?? null, 'normalized removal records branch deletion failure'); +bounded_cleanup_processed_candidates_assert_same('refs/dmc/recovery/0123456789abcdef0123456789abcdef01234567', $locked['remove']['recovery_ref'] ?? null, 'removal records retain the durable recovery ref'); +bounded_cleanup_processed_candidates_assert_same(true, str_contains((string) ($locked['remove']['recovery_command'] ?? ''), 'worktree add --detach'), 'removal records expose a reconstruction command'); $removed_outcome = array_merge($locked['remove'], array( 'path_exists_after' => false )); $removed_processed = $harness->processed($locked['validated'], 'removed', $removed_outcome); bounded_cleanup_processed_candidates_assert_same('removed', $removed_processed['final_action'], 'branch deletion failure does not discard successful worktree removal'); bounded_cleanup_processed_candidates_assert_same(false, $removed_processed['local_branch_deleted'], 'processed evidence records retained local branch'); bounded_cleanup_processed_candidates_assert_same($branch_delete_error, $removed_processed['branch_delete_error'], 'processed evidence records the branch deletion failure'); +bounded_cleanup_processed_candidates_assert_same($locked['remove']['recovery_ref'], $removed_processed['recovery_ref'], 'processed evidence retains the durable recovery ref'); $callback_harness->remove_result = null; mkdir($callback_path); diff --git a/tests/worktree-add-lifecycle.php b/tests/worktree-add-lifecycle.php index 46a5521f..3046b012 100644 --- a/tests/worktree-add-lifecycle.php +++ b/tests/worktree-add-lifecycle.php @@ -761,6 +761,7 @@ protected function inspect_worktree_capacity( string $repo, string $branch, bool assert_true(is_wp_error($disposable_mismatch) && 'disposable_intent_mismatch' === ( $disposable_mismatch->get_error_data()['reuse']['reason_code'] ?? '' ), 'incompatible disposable reuse did not return typed intent evidence'); $disposable_finalized = $workspace->worktree_finalize('homeboy@purpose-owned-disposable', 'active', null, 'success'); assert_true(! is_wp_error($disposable_finalized) && 'cleanup_eligible' === ( $disposable_finalized['lifecycle_state'] ?? '' ), 'successful owner terminal outcome did not make disposable worktree cleanup eligible'); + assert_true('run-991' === ( $disposable_finalized['metadata']['finalized_owner_run_ref'] ?? null ) && 'run-991' === ( $disposable_finalized['metadata']['owner_terminal_owner_run_ref'] ?? null ), 'terminal finalization did not bind its cleanup authority to the current owner'); assert_true(strtotime((string) ( $disposable_finalized['metadata']['last_seen_at'] ?? '' )) < strtotime((string) ( $disposable_finalized['metadata']['finalized_at'] ?? '' )), 'terminal finalization must not refresh heartbeat activity'); $GLOBALS['datamachine_code_test_filters']['datamachine_code_remote_workspace_backend_should_handle'] = static fn(): bool => true; $disposable_show = WorkspaceAbilities::showRepo(array( 'name' => 'homeboy@purpose-owned-disposable' )); @@ -813,9 +814,26 @@ protected function inspect_worktree_capacity( string $repo, string $branch, bool WorktreeContextInjector::store_lifecycle_metadata($claim_handle, array( 'last_seen_at' => gmdate('c'), 'origin_agent' => '', 'origin_session' => '', 'origin_user' => '', 'owner_run_ref' => '' )); $fresh_claim = $workspace->worktree_add('homeboy', 'claim-expired', 'origin/main', false, false, false, false, true, array( 'task_url' => 'https://example.test/issues/claim-expired' ), false, false, $claim_intent, 'claim_expired'); assert_true(is_wp_error($fresh_claim) && 'fresh_unattributed_heartbeat' === ( $fresh_claim->get_error_data()['reuse']['reason_code'] ?? null ) && 0 <= (int) ( $fresh_claim->get_error_data()['reuse']['liveness_evidence']['heartbeat_age_seconds'] ?? -1 ) && WorktreeContextInjector::DEFAULT_HEARTBEAT_TTL_SECONDS === (int) ( $fresh_claim->get_error_data()['reuse']['liveness_evidence']['heartbeat_ttl_seconds'] ?? 0 ) && array( 'origin_agent', 'origin_session', 'origin_user', 'owner_run_ref' ) === ( $fresh_claim->get_error_data()['reuse']['liveness_evidence']['missing_ownership_fields'] ?? null ), 'fresh unattributed heartbeat did not refuse with complete deterministic evidence'); - WorktreeContextInjector::store_lifecycle_metadata($claim_handle, array( 'last_seen_at' => gmdate('c', time() - WorktreeContextInjector::DEFAULT_HEARTBEAT_TTL_SECONDS - 1) )); + $owner_a_finalized_at = gmdate('c', time() - 3600); + WorktreeContextInjector::store_lifecycle_metadata($claim_handle, array( + 'last_seen_at' => gmdate('c', time() - WorktreeContextInjector::DEFAULT_HEARTBEAT_TTL_SECONDS - 1), + 'lifecycle_state' => WorktreeContextInjector::STATE_CLEANUP_ELIGIBLE, + 'purpose' => 'previous-owner', + 'owner_run_ref' => 'old-run', + 'cleanup_policy' => WorktreeContextInjector::CLEANUP_POLICY_REMOVE_ON_SUCCESS, + 'owner_terminal_outcome' => 'success', + 'owner_terminal_at' => $owner_a_finalized_at, + 'owner_terminal_owner_run_ref' => 'old-run', + 'finalized_at' => $owner_a_finalized_at, + 'finalized_state' => WorktreeContextInjector::STATE_ACTIVE, + 'finalized_owner_run_ref' => 'old-run', + 'cleanup_eligible_at' => $owner_a_finalized_at, + )); $claimed = $workspace->worktree_add('homeboy', 'claim-expired', 'origin/main', false, false, false, false, true, array( 'task_url' => 'https://example.test/issues/claim-expired' ), false, false, $claim_intent, 'claim_expired'); - assert_true(! is_wp_error($claimed) && true === ( $claimed['claimed'] ?? false ) && 'expired_unattributed_heartbeat' === ( $claimed['claim']['reason_code'] ?? null ) && 'claim-run-1' === ( $claimed['metadata']['owner_run_ref'] ?? null ) && '' === ( $claimed['metadata']['ownership_lineage'][0]['previous_owner_run_ref'] ?? 'not-empty' ), is_wp_error($claimed) ? $claimed->get_error_message() : 'expired anonymous heartbeat was not safely claimed with ownership lineage'); + assert_true(! is_wp_error($claimed) && true === ( $claimed['claimed'] ?? false ) && 'terminal_exact_handle' === ( $claimed['claim']['reason_code'] ?? null ) && WorktreeContextInjector::STATE_ACTIVE === ( $claimed['metadata']['lifecycle_state'] ?? null ) && 'claim-run-1' === ( $claimed['metadata']['owner_run_ref'] ?? null ) && 'old-run' === ( $claimed['metadata']['ownership_lineage'][0]['previous_owner_run_ref'] ?? null ), is_wp_error($claimed) ? $claimed->get_error_message() : 'owner B could not claim owner A terminal worktree with ownership lineage'); + foreach ( array( 'owner_terminal_outcome', 'owner_terminal_at', 'owner_terminal_owner_run_ref', 'finalized_at', 'finalized_state', 'finalized_owner_run_ref', 'cleanup_eligible_at' ) as $terminal_field ) { + assert_true(! array_key_exists($terminal_field, $claimed['metadata']), sprintf('ownership claim retained stale terminal field %s', $terminal_field)); + } // Simulate a caller being terminated after checkout materialization and after // bootstrap starts: its durable running phase must block readiness until the // exact compatible add retry completes bootstrap. @@ -1018,7 +1036,7 @@ protected function worktree_behind_count( string $repo_path, string $ref, string putenv('DMC_FINALIZER_STATUS_DELAY'); putenv('PATH=' . ( false === $original_path ? '' : $original_path )); unset($GLOBALS['datamachine_code_test_filters']['datamachine_code_workspace_target_lookup_timeout_seconds']); - assert_true(! is_wp_error($clean_finalization), 'clean terminal worktree finalization failed'); + assert_true(! is_wp_error($clean_finalization), is_wp_error($clean_finalization) ? 'clean terminal worktree finalization failed: ' . $clean_finalization->get_error_code() . ' ' . $clean_finalization->get_error_message() : 'clean terminal worktree finalization failed'); assert_true('cleanup_eligible' === ( $clean_finalization['lifecycle_state'] ?? '' ), 'clean terminal finalization did not expose cleanup eligibility'); assert_true($elapsed >= 5.5 && $elapsed < 8.5, sprintf('large clean-worktree finalization did not honor its deterministic process budget: %.3fs', $elapsed)); diff --git a/tests/worktree-cleanup-recovery-ref.php b/tests/worktree-cleanup-recovery-ref.php new file mode 100644 index 00000000..baa637c9 --- /dev/null +++ b/tests/worktree-cleanup-recovery-ref.php @@ -0,0 +1,115 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): mixed { return $this->data; } + } +} +if ( ! function_exists('is_wp_error') ) { + function is_wp_error( mixed $value ): bool { return $value instanceof WP_Error; } +} + +require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceWorktreeCleanupEngine.php'; + +use DataMachineCode\Workspace\WorkspaceWorktreeCleanupEngine; + +function cleanup_recovery_run( string $path, string $command ): string { + $output = array(); + $status = 0; + exec('git -C ' . escapeshellarg($path) . ' ' . $command . ' 2>&1', $output, $status); + if ( 0 !== $status ) { + throw new RuntimeException(implode("\n", $output)); + } + return trim(implode("\n", $output)); +} + +function cleanup_recovery_assert( bool $condition, string $message ): void { + if ( ! $condition ) { + throw new RuntimeException($message); + } +} + +function cleanup_recovery_remove_fixture( string $path ): void { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ( $iterator as $entry ) { + if ( $entry->isDir() && ! $entry->isLink() ) { + rmdir($entry->getPathname()); + } else { + unlink($entry->getPathname()); + } + } + rmdir($path); +} + +final class CleanupRecoveryHarness { + use WorkspaceWorktreeCleanupEngine; + protected const CLEANUP_GIT_PROBE_TIMEOUT = 5; + public string $workspace_path; + + public function __construct(private string $primary) { + $this->workspace_path = dirname($primary); + } + + public function preserve( array $candidate ): array|WP_Error { + $method = new ReflectionMethod($this, 'preserve_cleanup_recovery_ref'); + return $method->invoke($this, $candidate); + } + + private function get_primary_path( string $repo ): string { + return $this->primary; + } + + private function run_git( string $path, string $command, int $timeout = 0 ): array|WP_Error { + try { + return array( 'output' => cleanup_recovery_run($path, $command) ); + } catch ( RuntimeException $error ) { + return new WP_Error('git_failed', $error->getMessage()); + } + } +} + +$root = sys_get_temp_dir() . '/dmc-cleanup-recovery-' . getmypid(); +$primary = $root . '/example'; +$work = $root . '/example@candidate'; +mkdir($primary, 0777, true); +cleanup_recovery_run($primary, 'init -q'); +cleanup_recovery_run($primary, 'config user.name Test'); +cleanup_recovery_run($primary, 'config user.email test@example.test'); +file_put_contents($primary . '/base.txt', "base\n"); +cleanup_recovery_run($primary, 'add base.txt'); +cleanup_recovery_run($primary, 'commit -qm base'); +cleanup_recovery_run($primary, 'worktree add -qb candidate ' . escapeshellarg($work)); +file_put_contents($work . '/candidate.txt', "candidate\n"); +cleanup_recovery_run($work, 'add candidate.txt'); +cleanup_recovery_run($work, 'commit -qm candidate'); +$commit = cleanup_recovery_run($work, 'rev-parse HEAD'); + +$harness = new CleanupRecoveryHarness($primary); +$recovery = $harness->preserve(array( 'repo' => 'example', 'path' => $work )); +cleanup_recovery_assert(! is_wp_error($recovery), is_wp_error($recovery) ? $recovery->get_error_message() : 'recovery preservation failed'); +cleanup_recovery_assert('refs/dmc/recovery/' . $commit === ($recovery['recovery_ref'] ?? null), 'recovery ref does not identify the exact candidate commit'); + +cleanup_recovery_run($primary, 'worktree remove ' . escapeshellarg($work)); +cleanup_recovery_run($primary, 'branch -D candidate'); +cleanup_recovery_assert($commit === cleanup_recovery_run($primary, 'rev-parse --verify ' . escapeshellarg((string) $recovery['recovery_ref'])), 'recovery ref did not survive branch deletion'); +exec((string) $recovery['recovery_command'], $restore_output, $restore_status); +cleanup_recovery_assert(0 === $restore_status && is_dir($work), 'recovery command did not reconstruct the removed worktree'); +cleanup_recovery_assert($commit === cleanup_recovery_run($work, 'rev-parse HEAD'), 'reconstructed worktree does not resolve to the preserved commit'); + +cleanup_recovery_run($primary, 'worktree remove ' . escapeshellarg($work)); +cleanup_recovery_run($primary, 'update-ref -d ' . escapeshellarg((string) $recovery['recovery_ref']) . ' ' . escapeshellarg($commit)); +cleanup_recovery_remove_fixture($root); + +echo "worktree-cleanup-recovery-ref ok\n"; diff --git a/tests/worktree-retention-apply-protections.php b/tests/worktree-retention-apply-protections.php index d9372df5..f7a3460c 100644 --- a/tests/worktree-retention-apply-protections.php +++ b/tests/worktree-retention-apply-protections.php @@ -138,9 +138,9 @@ public function revalidate_reviewed( array $candidate, array $reviewed_lifecycle return $method->invoke($this, $candidate, false, false, false, $reviewed_lifecycle_snapshot); } - public function apply_reviewed( array $candidate ): array { + public function apply_reviewed( array $candidate, bool $discard_unpushed = false ): array { $method = new ReflectionMethod($this, 'apply_worktree_cleanup_plan_candidates'); - return $method->invoke($this, array( $candidate ), false, microtime(true)); + return $method->invoke($this, array( $candidate ), false, microtime(true), false, self::CLEANUP_GIT_REMOVE_TIMEOUT, $discard_unpushed); } public function remove_artifact( string $worktree_path, string $relative ): array|WP_Error { @@ -330,6 +330,39 @@ static function () use ( $metadata_file, $ready, $prelock_probe ): string { retention_apply_protections_assert('live_worktree' === ( $became_live['skipped']['reason_code'] ?? null ), 'a worktree that becomes live after planning is protected during apply revalidation'); retention_apply_protections_assert('heartbeat_fresh' === ( $became_live['skipped']['liveness_reason'] ?? null ), 'apply-time protection surfaces fresh liveness evidence'); + $owner_a_finalized_at = gmdate('c', time() - 172800); + $owner_a_candidate = $base_candidate; + $owner_a_candidate['metadata'] = array_merge($owner_a_candidate['metadata'], array( + 'purpose' => 'coding-session', + 'owner_run_ref' => 'owner-a', + 'cleanup_policy' => WorktreeContextInjector::CLEANUP_POLICY_REMOVE_ON_SUCCESS, + 'owner_terminal_outcome' => 'success', + 'owner_terminal_at' => $owner_a_finalized_at, + 'owner_terminal_owner_run_ref' => 'owner-a', + 'finalized_at' => $owner_a_finalized_at, + 'finalized_state' => WorktreeContextInjector::STATE_CLEANUP_ELIGIBLE, + 'finalized_owner_run_ref' => 'owner-a', + 'cleanup_eligible_at' => $owner_a_finalized_at, + )); + $owner_b_claimed_at = gmdate('c', time() - 90000); + $owner_b_active = array_merge($owner_a_candidate['metadata'], array( + 'lifecycle_state' => WorktreeContextInjector::STATE_ACTIVE, + 'last_seen_at' => gmdate('c', time() - WorktreeContextInjector::DEFAULT_HEARTBEAT_TTL_SECONDS - 1), + 'owner_run_ref' => 'owner-b', + 'ownership_lineage' => array(array( + 'claimed_at' => $owner_b_claimed_at, + 'previous_owner_run_ref' => 'owner-a', + 'new_owner_run_ref' => 'owner-b', + )), + )); + $GLOBALS['retention_apply_metadata'][ $owner_a_candidate['handle'] ] = $owner_b_active; + retention_apply_protections_assert(! WorktreeContextInjector::has_cleanup_signal($owner_b_active), 'owner A finalization must not remain a cleanup signal after owner B claims the worktree'); + $harness->unpushed_count = 2; + $ownership_reuse_apply = $harness->apply_reviewed($owner_a_candidate, true); + retention_apply_protections_assert('active_lifecycle' === ( $ownership_reuse_apply['skipped'][0]['reason_code'] ?? null ), 'owner A terminal evidence must not authorize cleanup after owner B claims the worktree'); + retention_apply_protections_assert(is_dir($work) && is_dir($primary . '/.git/worktrees/fix-retention-safety'), 'cleanup must preserve the active unpushed worktree and its branch metadata after ownership reuse'); + $harness->unpushed_count = 0; + $metadata_file = $root . '/metadata.json'; $ready = $root . '/reactivator-ready'; $prelock_probe = $root . '/prelock-probe';