From 6a6314ca5ac0b07dc6183ed97babec8f799b9052 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 7 Jun 2026 07:37:01 -0400 Subject: [PATCH] fix: drain marked worktrees during abandoned cleanup --- inc/Cli/Commands/WorkspaceCommand.php | 63 ++++++++++++++++++++------- tests/smoke-worktree-cleanup-cli.php | 37 ++++++++++++++++ 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index f88c3eb..fc19574 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -2868,6 +2868,10 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra $result['summary']['would_reconcile'] = (int) ( $reconcile['summary']['proposed'] ?? 0 ); if ( $this->worktree_abandoned_stage_incomplete($reconcile) ) { + $bounded = $this->run_worktree_abandoned_bounded_apply($abilities['bounded_apply'], $result, $apply, $force, $limit, 'reconcile'); + if ( is_wp_error($bounded) ) { + return $bounded; + } $result['evidence']['budget_exhausted'] = $this->worktree_abandoned_budget_expired($deadline); $result['continuation'] = $this->build_worktree_abandoned_continuation('reconcile', $reconcile, $limit, $passes, $force, $until_budget); $result['next_commands'][] = (string) $result['continuation']['next_command']; @@ -2930,6 +2934,10 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra $result['summary']['would_mark_cleanup_eligible'] += $planned; if ( $this->worktree_abandoned_stage_incomplete($step) ) { + $bounded = $this->run_worktree_abandoned_bounded_apply($abilities['bounded_apply'], $result, $apply, $force, $limit, $step_stage); + if ( is_wp_error($bounded) ) { + return $bounded; + } $result['evidence']['budget_exhausted'] = $this->worktree_abandoned_budget_expired($deadline); $result['continuation'] = $this->build_worktree_abandoned_continuation($step_stage, $step, $limit, $passes, $force, $until_budget); $result['next_commands'][] = (string) $result['continuation']['next_command']; @@ -2937,25 +2945,11 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra } } - $bounded_input = array( - 'dry_run' => ! $apply, - 'force' => $force, - 'limit' => $limit, - 'source' => self::CLEANUP_CLI_SOURCE, - ); - $bounded = $abilities['bounded_apply']->execute($bounded_input); + $bounded = $this->run_worktree_abandoned_bounded_apply($abilities['bounded_apply'], $result, $apply, $force, $limit, sprintf('pass_%d', $pass)); if ( is_wp_error($bounded) ) { return $bounded; } - $result['steps'][ sprintf('bounded_apply_pass_%d', $pass) ] = $this->summarize_worktree_abandoned_step($bounded); - - $result['summary']['removed'] += (int) ( $bounded['summary']['removed'] ?? 0 ); - $result['summary']['would_remove'] += (int) ( $bounded['summary']['would_remove'] ?? 0 ); - $result['summary']['bytes_reclaimed'] += (int) ( $bounded['summary']['bytes_reclaimed'] ?? 0 ); - - $result['blocked'] = $this->merge_worktree_abandoned_blockers($result['blocked'], (array) ( $bounded['skipped'] ?? array() )); - $removed_or_would = (int) ( $bounded['summary']['removed'] ?? 0 ) + (int) ( $bounded['summary']['would_remove'] ?? 0 ); if ( 0 === $pass_marked && 0 === $removed_or_would ) { break; @@ -3040,6 +3034,45 @@ private function worktree_abandoned_stage_incomplete( array $step ): bool { return $next_offset > $current; } + /** + * Run bounded cleanup removal and merge its accounting into the abandoned result. + * + * @param object $ability Bounded cleanup ability. + * @param array $result Abandoned cleanup result accumulator. + * @param bool $apply Whether apply mode is active. + * @param bool $force Whether force mode is active. + * @param int $limit Removal page size. + * @param string $step_label Step label suffix. + * @return array|\WP_Error + */ + private function run_worktree_abandoned_bounded_apply( object $ability, array &$result, bool $apply, bool $force, int $limit, string $step_label ): array|\WP_Error { + $execute = array( $ability, 'execute' ); + if ( ! is_callable($execute) ) { + return new \WP_Error('worktree_abandoned_ability_invalid', 'Worktree abandoned cleanup ability is not executable.', array( 'status' => 500 )); + } + + $bounded = $execute( + array( + 'dry_run' => ! $apply, + 'force' => $force, + 'limit' => $limit, + 'source' => self::CLEANUP_CLI_SOURCE, + ) + ); + if ( is_wp_error($bounded) ) { + return $bounded; + } + + $result['steps'][ sprintf('bounded_apply_%s', $step_label) ] = $this->summarize_worktree_abandoned_step($bounded); + + $result['summary']['removed'] += (int) ( $bounded['summary']['removed'] ?? 0 ); + $result['summary']['would_remove'] += (int) ( $bounded['summary']['would_remove'] ?? 0 ); + $result['summary']['bytes_reclaimed'] += (int) ( $bounded['summary']['bytes_reclaimed'] ?? 0 ); + $result['blocked'] = $this->merge_worktree_abandoned_blockers($result['blocked'], (array) ( $bounded['skipped'] ?? array() )); + + return $bounded; + } + /** * Build continuation evidence for a partially drained abandoned-cleanup stage. * diff --git a/tests/smoke-worktree-cleanup-cli.php b/tests/smoke-worktree-cleanup-cli.php index 321260c..66aff88 100644 --- a/tests/smoke-worktree-cleanup-cli.php +++ b/tests/smoke-worktree-cleanup-cli.php @@ -377,6 +377,7 @@ class FakeActiveNoSignalAbility { public array $last_input = array(); public array $inputs = array(); + public ?int $stall_at_offset = null; private string $mode; public function __construct( string $mode ) @@ -393,6 +394,28 @@ public function execute( array $input ): array $budget = isset($input['until_budget']) && '' !== trim((string) $input['until_budget']) ? ' --until-budget=' . trim((string) $input['until_budget']) : ''; $dry_run = 'report' !== $this->mode && ! empty($input['dry_run']) ? ' --dry-run' : ''; $next_command = sprintf('studio wp datamachine-code workspace worktree active-no-signal-%s%s --limit=%d --offset=%d%s --format=json', $this->mode, $dry_run, $limit, $offset + $limit, $budget); + if ( null !== $this->stall_at_offset && $offset === $this->stall_at_offset ) { + return array( + 'success' => true, + 'mode' => 'active_no_signal_' . str_replace('-', '_', $this->mode), + 'dry_run' => ! empty($input['dry_run']), + 'summary' => array( + 'inspected' => 0, + 'planned' => 0, + 'written' => 0, + 'skipped' => 0, + ), + 'pagination' => array( + 'total' => $offset + $limit, + 'offset' => $offset, + 'limit' => $limit, + 'scanned' => 0, + 'partial' => true, + 'complete' => false, + 'next_offset' => $offset, + ), + ); + } if ( 'report' === $this->mode ) { return array( @@ -1140,6 +1163,20 @@ public function execute( array $input ): array datamachine_code_cleanup_assert('remote-clean' === ( $abandoned_remote_clean_resume_json['stage'] ?? '' ), 'abandoned remote-clean resume reports requested stage'); datamachine_code_cleanup_assert(11 === (int) ( $active_remote_clean_ability->last_input['offset'] ?? -1 ), 'abandoned resume forwards offset to remote-clean stage'); + $bounded_call_count_before_stalled_classifier = count($bounded_apply_ability->inputs); + $active_remote_clean_ability->stall_at_offset = 42; + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->worktree(array( 'abandoned' ), array( 'apply' => true, 'force' => true, 'stage' => 'remote-clean', 'offset' => 42, 'limit' => 10, 'passes' => 1, 'until-budget' => '30s', 'format' => 'json' )); + $abandoned_remote_clean_stalled_json = json_decode(WP_CLI::$logs[0] ?? '', true); + datamachine_code_cleanup_assert(JSON_ERROR_NONE === json_last_error(), 'abandoned stalled remote-clean JSON output parses cleanly'); + datamachine_code_cleanup_assert('remote-clean' === ( $abandoned_remote_clean_stalled_json['continuation']['stage'] ?? '' ), 'abandoned stalled remote-clean emits remote-clean continuation'); + datamachine_code_cleanup_assert(42 === (int) ( $abandoned_remote_clean_stalled_json['continuation']['offset'] ?? -1 ), 'abandoned stalled remote-clean keeps current offset continuation'); + datamachine_code_cleanup_assert(count($bounded_apply_ability->inputs) === $bounded_call_count_before_stalled_classifier + 1, 'abandoned drains bounded cleanup before returning stalled classifier continuation'); + datamachine_code_cleanup_assert(isset($abandoned_remote_clean_stalled_json['steps']['bounded_apply_remote-clean']), 'abandoned stalled classifier output includes bounded cleanup step'); + datamachine_code_cleanup_assert(1 === (int) ( $abandoned_remote_clean_stalled_json['summary']['removed'] ?? 0 ), 'abandoned stalled classifier summary includes bounded removals'); + $active_remote_clean_ability->stall_at_offset = null; + $reconcile_metadata_ability->stall_at_offset = 90; WP_CLI::$logs = array(); WP_CLI::$successes = array();