diff --git a/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php b/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php index 5c556dd4..138218ab 100644 --- a/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php +++ b/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php @@ -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; } @@ -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; } } @@ -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, @@ -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; } @@ -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(); @@ -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; } @@ -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; } diff --git a/tests/smoke-abandoned-cleanup-orchestrator.php b/tests/smoke-abandoned-cleanup-orchestrator.php index 11efdae7..d515f15c 100644 --- a/tests/smoke-abandoned-cleanup-orchestrator.php +++ b/tests/smoke-abandoned-cleanup-orchestrator.php @@ -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);