Skip to content

Commit a285291

Browse files
authored
fix: resume abandoned cleanup stages (#578)
1 parent 63f2d3f commit a285291

2 files changed

Lines changed: 152 additions & 24 deletions

File tree

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 138 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2264,9 +2264,13 @@ private function renderGitOperationResult( string $operation, array $result, arr
22642264
* : For `abandoned`, maximum apply passes to run after marking eligible rows.
22652265
* Preview mode always runs a single non-destructive classification pass.
22662266
*
2267+
* [--stage=<stage>]
2268+
* : For `abandoned`, resume from a specific orchestration stage. Supported
2269+
* values: reconcile, finalized, equivalent-clean, merged, bounded.
2270+
*
22672271
* [--offset=<count>]
22682272
* : For `cleanup --dry-run`, `cleanup-artifacts --dry-run`,
2269-
* `reconcile-metadata`, and `active-no-signal-report`,
2273+
* `abandoned`, `reconcile-metadata`, and `active-no-signal-report`,
22702274
* pagination offset (0-indexed) into the inventory ordering. Walk pages by
22712275
* passing the previous response's `pagination.next_offset`.
22722276
*
@@ -2763,8 +2767,21 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
27632767
$force = ! empty($assoc_args['force']);
27642768
$limit = isset($assoc_args['limit']) ? max(1, min(100, (int) $assoc_args['limit'])) : 100;
27652769
$passes = isset($assoc_args['passes']) ? max(1, min(25, (int) $assoc_args['passes'])) : 5;
2770+
$offset = isset($assoc_args['offset']) ? max(0, (int) $assoc_args['offset']) : 0;
2771+
$stage = isset($assoc_args['stage']) ? strtolower( (string) preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $assoc_args['stage']) ) : 'reconcile';
2772+
$stage = str_replace('_', '-', $stage);
27662773
$until_budget = isset($assoc_args['until-budget']) && '' !== trim( (string) $assoc_args['until-budget']) ? trim( (string) $assoc_args['until-budget']) : '';
27672774
$deadline = null;
2775+
$stage_order = array(
2776+
'reconcile' => 0,
2777+
'finalized' => 1,
2778+
'equivalent-clean' => 2,
2779+
'merged' => 3,
2780+
'bounded' => 4,
2781+
);
2782+
if ( ! isset($stage_order[ $stage ]) ) {
2783+
return new \WP_Error('invalid_worktree_abandoned_stage', 'Invalid --stage value. Use reconcile, finalized, equivalent-clean, merged, or bounded.', array( 'status' => 400 ));
2784+
}
27682785
if ( '' !== $until_budget ) {
27692786
$budget_seconds = $this->parse_worktree_abandoned_budget($until_budget);
27702787
if ( is_wp_error($budget_seconds) ) {
@@ -2799,6 +2816,8 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
27992816
'destructive' => $apply,
28002817
'force' => $force,
28012818
'limit' => $limit,
2819+
'stage' => $stage,
2820+
'offset' => $offset,
28022821
'passes' => $passes,
28032822
'executed_passes' => 0,
28042823
'generated_at' => gmdate('c'),
@@ -2827,32 +2846,56 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
28272846
'source' => self::CLEANUP_CLI_SOURCE,
28282847
);
28292848

2830-
$reconcile_input = array_merge(
2831-
$common_page,
2832-
array(
2833-
'dry_run' => ! $apply,
2834-
'apply' => $apply,
2835-
)
2836-
);
2837-
$reconcile = $this->drain_worktree_abandoned_pages($abilities['reconcile_metadata'], $reconcile_input, $apply, $deadline);
2838-
if ( is_wp_error($reconcile) ) {
2839-
return $reconcile;
2849+
if ( $stage_order[ $stage ] <= $stage_order['reconcile'] ) {
2850+
$reconcile_input = array_merge(
2851+
$common_page,
2852+
array(
2853+
'dry_run' => ! $apply,
2854+
'apply' => $apply,
2855+
'offset' => 'reconcile' === $stage ? $offset : 0,
2856+
)
2857+
);
2858+
$reconcile = $this->drain_worktree_abandoned_pages($abilities['reconcile_metadata'], $reconcile_input, $apply, $deadline);
2859+
if ( is_wp_error($reconcile) ) {
2860+
return $reconcile;
2861+
}
2862+
$result['steps']['reconcile_metadata'] = $this->summarize_worktree_abandoned_step($reconcile);
2863+
$result['summary']['reconciled'] = (int) ( $reconcile['summary']['written'] ?? 0 );
2864+
$result['summary']['would_reconcile'] = (int) ( $reconcile['summary']['proposed'] ?? 0 );
2865+
2866+
if ( $this->worktree_abandoned_stage_incomplete($reconcile) ) {
2867+
$result['evidence']['budget_exhausted'] = $this->worktree_abandoned_budget_expired($deadline);
2868+
$result['continuation'] = $this->build_worktree_abandoned_continuation('reconcile', $reconcile, $limit, $passes, $force, $until_budget);
2869+
$result['next_commands'][] = (string) $result['continuation']['next_command'];
2870+
return $this->finalize_worktree_abandoned_result($result, $apply, $force, $limit, $passes, $until_budget, $started_at);
2871+
}
28402872
}
2841-
$result['steps']['reconcile_metadata'] = $this->summarize_worktree_abandoned_step($reconcile);
2842-
$result['summary']['reconciled'] = (int) ( $reconcile['summary']['written'] ?? 0 );
2843-
$result['summary']['would_reconcile'] = (int) ( $reconcile['summary']['proposed'] ?? 0 );
28442873

28452874
$mark_steps = array(
2846-
'finalized' => $abilities['finalized'],
2847-
'equivalent_clean' => $abilities['equivalent_clean'],
2848-
'merged' => $abilities['merged'],
2875+
'finalized' => array(
2876+
'stage' => 'finalized',
2877+
'ability' => $abilities['finalized'],
2878+
),
2879+
'equivalent_clean' => array(
2880+
'stage' => 'equivalent-clean',
2881+
'ability' => $abilities['equivalent_clean'],
2882+
),
2883+
'merged' => array(
2884+
'stage' => 'merged',
2885+
'ability' => $abilities['merged'],
2886+
),
28492887
);
28502888

28512889
$effective_passes = $apply ? $passes : 1;
28522890
for ( $pass = 1; $pass <= $effective_passes; ++$pass ) {
28532891
$result['executed_passes'] = $pass;
28542892
$pass_marked = 0;
2855-
foreach ( $mark_steps as $key => $ability ) {
2893+
foreach ( $mark_steps as $key => $step_config ) {
2894+
$step_stage = (string) $step_config['stage'];
2895+
if ( $stage_order[ $step_stage ] < $stage_order[ $stage ] ) {
2896+
continue;
2897+
}
2898+
28562899
if ( $this->worktree_abandoned_budget_expired($deadline) ) {
28572900
$result['evidence']['budget_exhausted'] = true;
28582901
break 2;
@@ -2862,10 +2905,10 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
28622905
$common_page,
28632906
array(
28642907
'dry_run' => ! $apply,
2865-
'offset' => 0,
2908+
'offset' => $step_stage === $stage ? $offset : 0,
28662909
)
28672910
);
2868-
$step = $this->drain_worktree_abandoned_pages($ability, $step_input, $apply, $deadline);
2911+
$step = $this->drain_worktree_abandoned_pages($step_config['ability'], $step_input, $apply, $deadline);
28692912
if ( is_wp_error($step) ) {
28702913
return $step;
28712914
}
@@ -2877,6 +2920,13 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
28772920
$pass_marked += $apply ? $written : $planned;
28782921
$result['summary']['marked_cleanup_eligible'] += $written;
28792922
$result['summary']['would_mark_cleanup_eligible'] += $planned;
2923+
2924+
if ( $this->worktree_abandoned_stage_incomplete($step) ) {
2925+
$result['evidence']['budget_exhausted'] = $this->worktree_abandoned_budget_expired($deadline);
2926+
$result['continuation'] = $this->build_worktree_abandoned_continuation($step_stage, $step, $limit, $passes, $force, $until_budget);
2927+
$result['next_commands'][] = (string) $result['continuation']['next_command'];
2928+
break 2;
2929+
}
28802930
}
28812931

28822932
$bounded_input = array(
@@ -2918,18 +2968,37 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
29182968
);
29192969
}
29202970

2921-
$result['blocked'] = array_values($result['blocked']);
2971+
return $this->finalize_worktree_abandoned_result($result, $apply, $force, $limit, $passes, $until_budget, $started_at);
2972+
}
2973+
2974+
/**
2975+
* Finalize abandoned cleanup output.
2976+
*
2977+
* @param array<string,mixed> $result Partial result.
2978+
* @param bool $apply Whether apply mode is active.
2979+
* @param bool $force Whether force mode is active.
2980+
* @param int $limit Page size.
2981+
* @param int $passes Apply passes.
2982+
* @param string $until_budget Original budget argument.
2983+
* @param float $started_at Start time.
2984+
* @return array<string,mixed>
2985+
*/
2986+
private function finalize_worktree_abandoned_result( array $result, bool $apply, bool $force, int $limit, int $passes, string $until_budget, float $started_at ): array {
2987+
$result['blocked'] = array_values( (array) ( $result['blocked'] ?? array() ) );
29222988
$result['summary']['blocked'] = count($result['blocked']);
29232989
foreach ( $result['blocked'] as $row ) {
2990+
if ( ! is_array($row) ) {
2991+
continue;
2992+
}
29242993
$reason = (string) ( $row['reason_code'] ?? 'unknown' );
29252994

29262995
$result['summary']['blocked_by_reason'][ $reason ] = (int) ( $result['summary']['blocked_by_reason'][ $reason ] ?? 0 ) + 1;
29272996
}
29282997

2929-
if ( ! $apply ) {
2998+
if ( empty($result['continuation']) && ! $apply ) {
29302999
$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 : '');
29313000
}
2932-
if ( ! $force ) {
3001+
if ( empty($result['continuation']) && ! $force ) {
29333002
$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 : '');
29343003
}
29353004

@@ -2938,6 +3007,52 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
29383007
return $result;
29393008
}
29403009

3010+
/**
3011+
* Determine whether a paged abandoned-cleanup stage still has remaining rows.
3012+
*
3013+
* @param array<string,mixed> $step Stage result.
3014+
* @return bool
3015+
*/
3016+
private function worktree_abandoned_stage_incomplete( array $step ): bool {
3017+
$pagination = (array) ( $step['pagination'] ?? $step['continuation'] ?? array() );
3018+
if ( empty($pagination) || ! empty($pagination['complete']) || ! isset($pagination['next_offset']) ) {
3019+
return false;
3020+
}
3021+
3022+
$next_offset = (int) $pagination['next_offset'];
3023+
$current = (int) ( $pagination['offset'] ?? 0 );
3024+
$total = isset($pagination['total']) ? (int) $pagination['total'] : null;
3025+
if ( null !== $total && $next_offset >= $total ) {
3026+
return false;
3027+
}
3028+
3029+
return $next_offset > $current;
3030+
}
3031+
3032+
/**
3033+
* Build continuation evidence for a partially drained abandoned-cleanup stage.
3034+
*
3035+
* @param string $stage Stage name.
3036+
* @param array<string,mixed> $step Stage result.
3037+
* @param int $limit Page size.
3038+
* @param int $passes Apply passes.
3039+
* @param bool $force Whether force mode is active.
3040+
* @param string $until_budget Original budget argument.
3041+
* @return array<string,mixed>
3042+
*/
3043+
private function build_worktree_abandoned_continuation( string $stage, array $step, int $limit, int $passes, bool $force, string $until_budget ): array {
3044+
$pagination = (array) ( $step['pagination'] ?? $step['continuation'] ?? array() );
3045+
$next_offset = isset($pagination['next_offset']) ? max(0, (int) $pagination['next_offset']) : 0;
3046+
$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 : '');
3047+
3048+
return array(
3049+
'stage' => $stage,
3050+
'offset' => $next_offset,
3051+
'next_command' => $command,
3052+
'pagination' => $pagination,
3053+
);
3054+
}
3055+
29413056
/**
29423057
* Drain paginated abandoned-cleanup classifier pages.
29433058
*

tests/smoke-worktree-cleanup-cli.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,7 @@ public function execute( array $input ): array
855855
datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--apply]"), 'worktree synopsis declares --apply at top level');
856856
datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--via-jobs]"), 'worktree synopsis declares --via-jobs at top level');
857857
datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--passes=<count>]"), 'worktree synopsis declares abandoned --passes at top level');
858+
datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--stage=<stage>]"), 'worktree synopsis declares abandoned --stage at top level');
858859
datamachine_code_cleanup_assert(! str_contains($doc_comment, "\n\t\t * [--apply-plan=<file>]"), 'cleanup flags are not hidden behind nested docblock indentation');
859860
datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, 'Control task-backed workspace cleanup runs.'), 'workspace cleanup command documents task-backed controller surface');
860861
datamachine_code_cleanup_assert(str_contains($cleanup_doc_comment, '<plan|apply|run|status|resume|cancel|evidence>'), 'workspace cleanup synopsis exposes DB-backed and task-backed cleanup operations');
@@ -1088,13 +1089,25 @@ public function execute( array $input ): array
10881089
datamachine_code_cleanup_assert(2 === (int) ( $abandoned_json['summary']['blocked'] ?? 0 ), 'abandoned summary reports blocked rows');
10891090
datamachine_code_cleanup_assert(1 === (int) ( $abandoned_json['summary']['blocked_by_reason']['unpushed_commits'] ?? 0 ), 'abandoned preserves unpushed-commit blocker evidence');
10901091

1092+
$reconcile_call_count = count($reconcile_metadata_ability->inputs);
1093+
WP_CLI::$logs = array();
1094+
WP_CLI::$successes = array();
1095+
$command->worktree(array( 'abandoned' ), array( 'apply' => true, 'stage' => 'finalized', 'offset' => 7, 'limit' => 1, 'passes' => 1, 'format' => 'json' ));
1096+
$abandoned_resume_json = json_decode(WP_CLI::$logs[0] ?? '', true);
1097+
datamachine_code_cleanup_assert(JSON_ERROR_NONE === json_last_error(), 'abandoned resume JSON output parses cleanly');
1098+
datamachine_code_cleanup_assert('finalized' === ( $abandoned_resume_json['stage'] ?? '' ), 'abandoned resume reports requested stage');
1099+
datamachine_code_cleanup_assert(7 === (int) ( $abandoned_resume_json['offset'] ?? 0 ), 'abandoned resume reports requested offset');
1100+
datamachine_code_cleanup_assert($reconcile_call_count === count($reconcile_metadata_ability->inputs), 'abandoned resume skips completed reconciliation stage');
1101+
datamachine_code_cleanup_assert(7 === (int) ( $active_finalized_ability->last_input['offset'] ?? -1 ), 'abandoned resume forwards offset to requested classifier stage');
1102+
1103+
$prune_calls_before_preview = $prune_ability->calls;
10911104
WP_CLI::$logs = array();
10921105
WP_CLI::$successes = array();
10931106
$command->worktree(array( 'abandoned' ), array( 'limit' => 5, 'passes' => 3, 'format' => 'json' ));
10941107
$abandoned_preview_json = json_decode(WP_CLI::$logs[0] ?? '', true);
10951108
datamachine_code_cleanup_assert(false === ( $abandoned_preview_json['applied'] ?? null ), 'abandoned preview leaves apply mode false');
10961109
datamachine_code_cleanup_assert(1 === (int) ( $abandoned_preview_json['executed_passes'] ?? 0 ), 'abandoned preview runs one classification pass even when more passes are requested');
1097-
datamachine_code_cleanup_assert(1 === $prune_ability->calls, 'abandoned preview does not prune git metadata');
1110+
datamachine_code_cleanup_assert($prune_calls_before_preview === $prune_ability->calls, 'abandoned preview does not prune git metadata');
10981111
datamachine_code_cleanup_assert(true === ( $abandoned_preview_json['steps']['prune']['skipped'] ?? false ), 'abandoned preview explains skipped prune step');
10991112

11001113
echo "\n[1b] --apply-plan decodes JSON and forbids force\n";

0 commit comments

Comments
 (0)