From c22fa23990466f670cada22bf9b61a848e56d879 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 6 Jun 2026 17:53:44 -0400 Subject: [PATCH] fix: resume abandoned cleanup stages --- inc/Cli/Commands/WorkspaceCommand.php | 161 ++++++++++++++++++++++---- tests/smoke-worktree-cleanup-cli.php | 15 ++- 2 files changed, 152 insertions(+), 24 deletions(-) diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 5c38bb2..c89fe07 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -2264,9 +2264,13 @@ private function renderGitOperationResult( string $operation, array $result, arr * : For `abandoned`, maximum apply passes to run after marking eligible rows. * Preview mode always runs a single non-destructive classification pass. * + * [--stage=] + * : For `abandoned`, resume from a specific orchestration stage. Supported + * values: reconcile, finalized, equivalent-clean, merged, bounded. + * * [--offset=] * : For `cleanup --dry-run`, `cleanup-artifacts --dry-run`, - * `reconcile-metadata`, and `active-no-signal-report`, + * `abandoned`, `reconcile-metadata`, and `active-no-signal-report`, * pagination offset (0-indexed) into the inventory ordering. Walk pages by * passing the previous response's `pagination.next_offset`. * @@ -2763,8 +2767,21 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra $force = ! empty($assoc_args['force']); $limit = isset($assoc_args['limit']) ? max(1, min(100, (int) $assoc_args['limit'])) : 100; $passes = isset($assoc_args['passes']) ? max(1, min(25, (int) $assoc_args['passes'])) : 5; + $offset = isset($assoc_args['offset']) ? max(0, (int) $assoc_args['offset']) : 0; + $stage = isset($assoc_args['stage']) ? strtolower( (string) preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $assoc_args['stage']) ) : 'reconcile'; + $stage = str_replace('_', '-', $stage); $until_budget = isset($assoc_args['until-budget']) && '' !== trim( (string) $assoc_args['until-budget']) ? trim( (string) $assoc_args['until-budget']) : ''; $deadline = null; + $stage_order = array( + 'reconcile' => 0, + 'finalized' => 1, + 'equivalent-clean' => 2, + 'merged' => 3, + 'bounded' => 4, + ); + if ( ! isset($stage_order[ $stage ]) ) { + return new \WP_Error('invalid_worktree_abandoned_stage', 'Invalid --stage value. Use reconcile, finalized, equivalent-clean, merged, or bounded.', array( 'status' => 400 )); + } if ( '' !== $until_budget ) { $budget_seconds = $this->parse_worktree_abandoned_budget($until_budget); if ( is_wp_error($budget_seconds) ) { @@ -2799,6 +2816,8 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra 'destructive' => $apply, 'force' => $force, 'limit' => $limit, + 'stage' => $stage, + 'offset' => $offset, 'passes' => $passes, 'executed_passes' => 0, 'generated_at' => gmdate('c'), @@ -2827,32 +2846,56 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra 'source' => self::CLEANUP_CLI_SOURCE, ); - $reconcile_input = array_merge( - $common_page, - array( - 'dry_run' => ! $apply, - 'apply' => $apply, - ) - ); - $reconcile = $this->drain_worktree_abandoned_pages($abilities['reconcile_metadata'], $reconcile_input, $apply, $deadline); - if ( is_wp_error($reconcile) ) { - return $reconcile; + if ( $stage_order[ $stage ] <= $stage_order['reconcile'] ) { + $reconcile_input = array_merge( + $common_page, + array( + 'dry_run' => ! $apply, + 'apply' => $apply, + 'offset' => 'reconcile' === $stage ? $offset : 0, + ) + ); + $reconcile = $this->drain_worktree_abandoned_pages($abilities['reconcile_metadata'], $reconcile_input, $apply, $deadline); + if ( is_wp_error($reconcile) ) { + return $reconcile; + } + $result['steps']['reconcile_metadata'] = $this->summarize_worktree_abandoned_step($reconcile); + $result['summary']['reconciled'] = (int) ( $reconcile['summary']['written'] ?? 0 ); + $result['summary']['would_reconcile'] = (int) ( $reconcile['summary']['proposed'] ?? 0 ); + + if ( $this->worktree_abandoned_stage_incomplete($reconcile) ) { + $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']; + return $this->finalize_worktree_abandoned_result($result, $apply, $force, $limit, $passes, $until_budget, $started_at); + } } - $result['steps']['reconcile_metadata'] = $this->summarize_worktree_abandoned_step($reconcile); - $result['summary']['reconciled'] = (int) ( $reconcile['summary']['written'] ?? 0 ); - $result['summary']['would_reconcile'] = (int) ( $reconcile['summary']['proposed'] ?? 0 ); $mark_steps = array( - 'finalized' => $abilities['finalized'], - 'equivalent_clean' => $abilities['equivalent_clean'], - 'merged' => $abilities['merged'], + 'finalized' => array( + 'stage' => 'finalized', + 'ability' => $abilities['finalized'], + ), + 'equivalent_clean' => array( + 'stage' => 'equivalent-clean', + 'ability' => $abilities['equivalent_clean'], + ), + 'merged' => array( + 'stage' => 'merged', + 'ability' => $abilities['merged'], + ), ); $effective_passes = $apply ? $passes : 1; for ( $pass = 1; $pass <= $effective_passes; ++$pass ) { $result['executed_passes'] = $pass; $pass_marked = 0; - foreach ( $mark_steps as $key => $ability ) { + foreach ( $mark_steps as $key => $step_config ) { + $step_stage = (string) $step_config['stage']; + if ( $stage_order[ $step_stage ] < $stage_order[ $stage ] ) { + continue; + } + if ( $this->worktree_abandoned_budget_expired($deadline) ) { $result['evidence']['budget_exhausted'] = true; break 2; @@ -2862,10 +2905,10 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra $common_page, array( 'dry_run' => ! $apply, - 'offset' => 0, + 'offset' => $step_stage === $stage ? $offset : 0, ) ); - $step = $this->drain_worktree_abandoned_pages($ability, $step_input, $apply, $deadline); + $step = $this->drain_worktree_abandoned_pages($step_config['ability'], $step_input, $apply, $deadline); if ( is_wp_error($step) ) { return $step; } @@ -2877,6 +2920,13 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra $pass_marked += $apply ? $written : $planned; $result['summary']['marked_cleanup_eligible'] += $written; $result['summary']['would_mark_cleanup_eligible'] += $planned; + + if ( $this->worktree_abandoned_stage_incomplete($step) ) { + $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']; + break 2; + } } $bounded_input = array( @@ -2918,18 +2968,37 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra ); } - $result['blocked'] = array_values($result['blocked']); + return $this->finalize_worktree_abandoned_result($result, $apply, $force, $limit, $passes, $until_budget, $started_at); + } + + /** + * Finalize abandoned cleanup output. + * + * @param array $result Partial result. + * @param bool $apply Whether apply mode is active. + * @param bool $force Whether force mode is active. + * @param int $limit Page size. + * @param int $passes Apply passes. + * @param string $until_budget Original budget argument. + * @param float $started_at Start time. + * @return array + */ + private function finalize_worktree_abandoned_result( array $result, bool $apply, bool $force, int $limit, int $passes, string $until_budget, float $started_at ): array { + $result['blocked'] = array_values( (array) ( $result['blocked'] ?? array() ) ); $result['summary']['blocked'] = count($result['blocked']); foreach ( $result['blocked'] as $row ) { + if ( ! is_array($row) ) { + continue; + } $reason = (string) ( $row['reason_code'] ?? 'unknown' ); $result['summary']['blocked_by_reason'][ $reason ] = (int) ( $result['summary']['blocked_by_reason'][ $reason ] ?? 0 ) + 1; } - if ( ! $apply ) { + if ( empty($result['continuation']) && ! $apply ) { $result['next_commands'][] = sprintf('studio wp datamachine-code workspace worktree abandoned --apply%s --limit=%d --passes=%d%s --format=json', $force ? ' --force' : '', $limit, $passes, '' !== $until_budget ? ' --until-budget=' . $until_budget : ''); } - if ( ! $force ) { + if ( empty($result['continuation']) && ! $force ) { $result['next_commands'][] = sprintf('studio wp datamachine-code workspace worktree abandoned --apply --force --limit=%d --passes=%d%s --format=json', $limit, $passes, '' !== $until_budget ? ' --until-budget=' . $until_budget : ''); } @@ -2938,6 +3007,52 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra return $result; } + /** + * Determine whether a paged abandoned-cleanup stage still has remaining rows. + * + * @param array $step Stage result. + * @return bool + */ + private function worktree_abandoned_stage_incomplete( array $step ): bool { + $pagination = (array) ( $step['pagination'] ?? $step['continuation'] ?? array() ); + if ( empty($pagination) || ! empty($pagination['complete']) || ! isset($pagination['next_offset']) ) { + return false; + } + + $next_offset = (int) $pagination['next_offset']; + $current = (int) ( $pagination['offset'] ?? 0 ); + $total = isset($pagination['total']) ? (int) $pagination['total'] : null; + if ( null !== $total && $next_offset >= $total ) { + return false; + } + + return $next_offset > $current; + } + + /** + * Build continuation evidence for a partially drained abandoned-cleanup stage. + * + * @param string $stage Stage name. + * @param array $step Stage result. + * @param int $limit Page size. + * @param int $passes Apply passes. + * @param bool $force Whether force mode is active. + * @param string $until_budget Original budget argument. + * @return array + */ + private function build_worktree_abandoned_continuation( string $stage, array $step, int $limit, int $passes, bool $force, string $until_budget ): array { + $pagination = (array) ( $step['pagination'] ?? $step['continuation'] ?? array() ); + $next_offset = isset($pagination['next_offset']) ? max(0, (int) $pagination['next_offset']) : 0; + $command = sprintf('studio wp datamachine-code workspace worktree abandoned --apply%s --stage=%s --offset=%d --limit=%d --passes=%d%s --format=json', $force ? ' --force' : '', $stage, $next_offset, $limit, $passes, '' !== $until_budget ? ' --until-budget=' . $until_budget : ''); + + return array( + 'stage' => $stage, + 'offset' => $next_offset, + 'next_command' => $command, + 'pagination' => $pagination, + ); + } + /** * Drain paginated abandoned-cleanup classifier pages. * diff --git a/tests/smoke-worktree-cleanup-cli.php b/tests/smoke-worktree-cleanup-cli.php index 27e783a..9e32b4b 100644 --- a/tests/smoke-worktree-cleanup-cli.php +++ b/tests/smoke-worktree-cleanup-cli.php @@ -855,6 +855,7 @@ public function execute( array $input ): array datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--apply]"), 'worktree synopsis declares --apply at top level'); datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--via-jobs]"), 'worktree synopsis declares --via-jobs at top level'); datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--passes=]"), 'worktree synopsis declares abandoned --passes at top level'); + datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--stage=]"), 'worktree synopsis declares abandoned --stage at top level'); datamachine_code_cleanup_assert(! str_contains($doc_comment, "\n\t\t * [--apply-plan=]"), 'cleanup flags are not hidden behind nested docblock indentation'); datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, 'Control task-backed workspace cleanup runs.'), 'workspace cleanup command documents task-backed controller surface'); datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, ''), 'workspace cleanup synopsis exposes DB-backed and task-backed cleanup operations'); @@ -1088,13 +1089,25 @@ public function execute( array $input ): array datamachine_code_cleanup_assert(2 === (int) ( $abandoned_json['summary']['blocked'] ?? 0 ), 'abandoned summary reports blocked rows'); datamachine_code_cleanup_assert(1 === (int) ( $abandoned_json['summary']['blocked_by_reason']['unpushed_commits'] ?? 0 ), 'abandoned preserves unpushed-commit blocker evidence'); + $reconcile_call_count = count($reconcile_metadata_ability->inputs); + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->worktree(array( 'abandoned' ), array( 'apply' => true, 'stage' => 'finalized', 'offset' => 7, 'limit' => 1, 'passes' => 1, 'format' => 'json' )); + $abandoned_resume_json = json_decode(WP_CLI::$logs[0] ?? '', true); + datamachine_code_cleanup_assert(JSON_ERROR_NONE === json_last_error(), 'abandoned resume JSON output parses cleanly'); + datamachine_code_cleanup_assert('finalized' === ( $abandoned_resume_json['stage'] ?? '' ), 'abandoned resume reports requested stage'); + datamachine_code_cleanup_assert(7 === (int) ( $abandoned_resume_json['offset'] ?? 0 ), 'abandoned resume reports requested offset'); + datamachine_code_cleanup_assert($reconcile_call_count === count($reconcile_metadata_ability->inputs), 'abandoned resume skips completed reconciliation stage'); + datamachine_code_cleanup_assert(7 === (int) ( $active_finalized_ability->last_input['offset'] ?? -1 ), 'abandoned resume forwards offset to requested classifier stage'); + + $prune_calls_before_preview = $prune_ability->calls; WP_CLI::$logs = array(); WP_CLI::$successes = array(); $command->worktree(array( 'abandoned' ), array( 'limit' => 5, 'passes' => 3, 'format' => 'json' )); $abandoned_preview_json = json_decode(WP_CLI::$logs[0] ?? '', true); datamachine_code_cleanup_assert(false === ( $abandoned_preview_json['applied'] ?? null ), 'abandoned preview leaves apply mode false'); datamachine_code_cleanup_assert(1 === (int) ( $abandoned_preview_json['executed_passes'] ?? 0 ), 'abandoned preview runs one classification pass even when more passes are requested'); - datamachine_code_cleanup_assert(1 === $prune_ability->calls, 'abandoned preview does not prune git metadata'); + datamachine_code_cleanup_assert($prune_calls_before_preview === $prune_ability->calls, 'abandoned preview does not prune git metadata'); datamachine_code_cleanup_assert(true === ( $abandoned_preview_json['steps']['prune']['skipped'] ?? false ), 'abandoned preview explains skipped prune step'); echo "\n[1b] --apply-plan decodes JSON and forbids force\n";