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
38 changes: 34 additions & 4 deletions inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ public function run( array $input ): array|\WP_Error {
'offset' => $step_stage === $stage ? $offset : 0,
)
);
$step = $this->drain_pages($step_config['ability'], $step_input, $apply, $deadline);
$step = $this->drain_pages($step_config['ability'], $step_input, $apply, $deadline, $active_no_signal_drain);
if ( is_wp_error($step) ) {
return $step;
}
Expand All @@ -174,8 +174,12 @@ public function run( array $input ): array|\WP_Error {
return $bounded;
}
$result['evidence']['budget_exhausted'] = $this->budget_expired($deadline);
$result['continuation'] = $this->build_continuation($step_stage, $step, $limit, $passes, $force, $until_budget, $active_no_signal_drain);
$result['next_commands'][] = (string) $result['continuation']['next_command'];
if ( ! empty($step['adaptive_stop']) && is_array($step['adaptive_stop']) ) {
$result['evidence']['adaptive_stop'] = $step['adaptive_stop'];
$result['summary']['stop_reason'] = (string) ( $step['adaptive_stop']['reason'] ?? 'no_progress_in_stage' );
}
$result['continuation'] = $this->build_continuation($step_stage, $step, $limit, $passes, $force, $until_budget, $active_no_signal_drain);
$result['next_commands'][] = (string) $result['continuation']['next_command'];
break 2;
}
}
Expand Down Expand Up @@ -524,6 +528,7 @@ private function build_continuation( string $stage, array $step, int $limit, int
$restart = ! empty($pagination['partial']) && $next_offset <= $current && $mutated > 0;
$command_offset = $restart ? 0 : $next_offset;
$command = $this->build_continuation_command($stage, $command_offset, $limit, $passes, $force, $until_budget, $active_no_signal_drain);
$adaptive = (array) ( $step['adaptive_stop'] ?? array() );

$continuation = array(
'stage' => $stage,
Expand All @@ -549,6 +554,13 @@ private function build_continuation( string $stage, array $step, int $limit, int
);
$continuation['next_command_label'] = 'Restart this stage from offset 0 because the cleanup candidate set changed.';
}
if ( ! empty($adaptive) ) {
$continuation['reason'] = (string) ( $adaptive['reason'] ?? 'no_progress_in_stage' );
$continuation['reason_description'] = (string) ( $adaptive['reason_description'] ?? 'The previous stage page scanned rows but produced no cleanup mutations, so the drain stopped before spending more budget on low-yield pages.' );
$continuation['recommendation'] = (string) ( $adaptive['recommendation'] ?? 'Stop this drain for now, or run next_command to continue this stage from the next page if you want a deeper scan.' );
$continuation['progress_delta'] = (array) ( $adaptive['progress_delta'] ?? array() );
$continuation['next_command_label'] = 'Continue this stage despite the no-progress adaptive stop.';
}

return $continuation;
}
Expand All @@ -569,7 +581,7 @@ private function build_continuation_command( string $stage, int $offset, int $li
return sprintf('studio wp datamachine-code workspace worktree %s --apply%s --stage=%s --offset=%d --limit=%d --passes=%d%s --format=json', $operation, $force ? ' --force' : '', $stage, $offset, $limit, $passes, '' !== $until_budget ? ' --until-budget=' . $until_budget : '');
}

private function drain_pages( object $ability, array $base_input, bool $apply, ?float $deadline = null ): array|\WP_Error {
private function drain_pages( object $ability, array $base_input, bool $apply, ?float $deadline = null, bool $stop_on_no_progress = false ): array|\WP_Error {
$pages = array();
$summary = array();
$pagination = array();
Expand Down Expand Up @@ -609,6 +621,7 @@ private function drain_pages( object $ability, array $base_input, bool $apply, ?

$next_offset = isset($pagination['next_offset']) ? (int) $pagination['next_offset'] : null;
$mutation_count = (int) ( $result['summary']['written'] ?? 0 ) + (int) ( $result['summary']['removed'] ?? 0 );
$inspected = (int) ( $result['summary']['inspected'] ?? $result['summary']['processed'] ?? 0 );
if ( null === $next_offset || ( $next_offset <= $offset && ( $mutation_count <= 0 || empty($pagination['partial']) ) ) ) {
break;
}
Expand All @@ -618,6 +631,23 @@ private function drain_pages( object $ability, array $base_input, bool $apply, ?
break;
}

if ( $stop_on_no_progress && $mutation_count <= 0 && $inspected > 0 ) {
$last_result['adaptive_stop'] = array(
'reason' => 'no_progress_in_stage',
'reason_description' => 'This stage scanned a page and produced no cleanup metadata writes or removals, so the drain stopped before spending more budget on low-yield pages.',
'recommendation' => 'Stop this drain for now, or run next_command to continue this stage from the next page if you want a deeper scan.',
'progress_delta' => array(
'inspected' => $inspected,
'written' => (int) ( $result['summary']['written'] ?? 0 ),
'removed' => (int) ( $result['summary']['removed'] ?? 0 ),
'total_mutations' => $mutation_count,
'previous_offset' => $offset,
'next_offset' => $next_offset,
),
);
break;
}

$offset = $next_offset;
}

Expand Down
36 changes: 36 additions & 0 deletions tests/smoke-abandoned-cleanup-orchestrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -326,4 +326,40 @@ static function () use ( &$clock_index ): float {
abandoned_cleanup_assert(0 === count($bounded_budget_abilities['datamachine-code/workspace-worktree-prune']->calls), 'bounded budget continuation skips prune after budget exhaustion');
abandoned_cleanup_assert(str_contains((string) $bounded_budget_result['continuation']['hint'], 'Re-run next_command'), 'bounded budget continuation has operator hint');

$zero_yield_finalized = new AbandonedCleanupQueuedAbility(
array(
array(
'mode' => 'finalized',
'summary' => array( 'inspected' => 10, 'written' => 0 ),
'pagination' => array( 'offset' => 0, 'limit' => 10, 'scanned' => 10, 'partial' => true, 'complete' => false, 'next_offset' => 10, 'total' => 30 ),
),
)
);
$zero_yield_equivalent = new AbandonedCleanupFakeAbility('equivalent_clean', array( 'inspected' => 10, 'written' => 1 ), array( 'complete' => true ));
$zero_yield_abilities = array(
'datamachine-code/workspace-worktree-reconcile-metadata' => new AbandonedCleanupFakeAbility('reconcile_metadata', array(), array( 'complete' => true )),
'datamachine-code/workspace-worktree-active-no-signal-finalized-apply' => $zero_yield_finalized,
'datamachine-code/workspace-worktree-active-no-signal-equivalent-clean-apply' => $zero_yield_equivalent,
'datamachine-code/workspace-worktree-active-no-signal-merged-apply' => new AbandonedCleanupFakeAbility('merged', array(), array( 'complete' => true )),
'datamachine-code/workspace-worktree-active-no-signal-remote-clean-apply' => new AbandonedCleanupFakeAbility('remote_clean', array(), array( 'complete' => true )),
'datamachine-code/workspace-worktree-active-no-signal-report' => new AbandonedCleanupFakeAbility('active_no_signal_report', array( 'total_active_no_signal' => 0, 'inspected' => 0, 'by_suggested_action' => array() ), array( 'complete' => true, 'total' => 0 )),
'datamachine-code/workspace-worktree-bounded-cleanup-eligible-apply' => new AbandonedCleanupFakeAbility('bounded', array( 'processed' => 0, 'removed' => 0 ), array( 'complete' => true )),
'datamachine-code/workspace-worktree-prune' => new AbandonedCleanupFakeAbility('prune'),
);
$orchestrator = new DataMachineCode\Workspace\WorkspaceAbandonedCleanupOrchestrator(
static fn( string $name ) => $zero_yield_abilities[ $name ] ?? null,
static fn(): float => 1000.0
);
$zero_yield_result = $orchestrator->run(array( 'active_no_signal_drain' => true, 'apply' => true, 'limit' => 10, 'passes' => 3, 'until_budget' => '300s' ));
abandoned_cleanup_assert(! is_wp_error($zero_yield_result), 'active/no-signal zero-yield result succeeds');
abandoned_cleanup_assert('no_progress_in_stage' === $zero_yield_result['continuation']['reason'], 'zero-yield continuation uses no-progress reason');
abandoned_cleanup_assert(false === (bool) ( $zero_yield_result['evidence']['budget_exhausted'] ?? true ), 'zero-yield stop is distinct from budget exhaustion');
abandoned_cleanup_assert('no_progress_in_stage' === ( $zero_yield_result['summary']['stop_reason'] ?? null ), 'zero-yield summary exposes stop reason');
abandoned_cleanup_assert(10 === (int) ( $zero_yield_result['continuation']['progress_delta']['inspected'] ?? 0 ), 'zero-yield continuation exposes inspected count');
abandoned_cleanup_assert(0 === (int) ( $zero_yield_result['continuation']['progress_delta']['total_mutations'] ?? -1 ), 'zero-yield continuation exposes zero mutations');
abandoned_cleanup_assert(str_contains((string) $zero_yield_result['continuation']['recommendation'], 'Stop this drain'), 'zero-yield continuation recommends stopping');
abandoned_cleanup_assert(str_contains((string) $zero_yield_result['continuation']['next_command'], '--stage=finalized --offset=10'), 'zero-yield continuation resumes same stage next page');
abandoned_cleanup_assert(0 === count($zero_yield_equivalent->calls), 'zero-yield stop avoids advancing into later active/no-signal stages');
abandoned_cleanup_assert(0 === count($zero_yield_abilities['datamachine-code/workspace-worktree-prune']->calls), 'zero-yield stop skips prune while continuation remains');

fwrite(STDOUT, 'abandoned cleanup orchestrator smoke passed' . PHP_EOL);
Loading