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
161 changes: 138 additions & 23 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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=<stage>]
* : For `abandoned`, resume from a specific orchestration stage. Supported
* values: reconcile, finalized, equivalent-clean, merged, bounded.
*
* [--offset=<count>]
* : 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`.
*
Expand Down Expand Up @@ -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) ) {
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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(
Expand Down Expand Up @@ -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<string,mixed> $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<string,mixed>
*/
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 : '');
}

Expand All @@ -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<string,mixed> $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<string,mixed> $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<string,mixed>
*/
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.
*
Expand Down
15 changes: 14 additions & 1 deletion tests/smoke-worktree-cleanup-cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -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=<count>]"), 'worktree synopsis declares abandoned --passes at top level');
datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--stage=<stage>]"), 'worktree synopsis declares abandoned --stage at top level');
datamachine_code_cleanup_assert(! str_contains($doc_comment, "\n\t\t * [--apply-plan=<file>]"), '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, '<plan|apply|run|status|resume|cancel|evidence>'), 'workspace cleanup synopsis exposes DB-backed and task-backed cleanup operations');
Expand Down Expand Up @@ -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";
Expand Down
Loading