From 929f403af6d763af43ca3e73aa7bc0716549d5c1 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:35:07 -0400 Subject: [PATCH 1/2] Stop active no-signal drain on zero-yield pages --- .../WorkspaceAbandonedCleanupOrchestrator.php | 34 ++++++++++++++++-- .../smoke-abandoned-cleanup-orchestrator.php | 36 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php b/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php index 3553caa1..ccf8568c 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,6 +174,10 @@ public function run( array $input ): array|\WP_Error { return $bounded; } $result['evidence']['budget_exhausted'] = $this->budget_expired($deadline); + 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; @@ -523,6 +527,7 @@ private function build_continuation( string $stage, array $step, int $limit, int $current = (int) ( $pagination['offset'] ?? 0 ); $mutated = (int) ( $step['summary']['written'] ?? 0 ) + (int) ( $step['summary']['removed'] ?? 0 ); $restart = ! empty($pagination['partial']) && $next_offset <= $current && $mutated > 0; + $adaptive = (array) ( $step['adaptive_stop'] ?? array() ); $continuation = array( 'stage' => $stage, @@ -547,6 +552,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; } @@ -567,7 +579,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(); @@ -607,6 +619,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; } @@ -616,6 +629,23 @@ private function drain_pages( object $ability, array $base_input, bool $apply, ? break; } + if ( $stop_on_no_progress && $apply && $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 f5c2347b..20b3515c 100644 --- a/tests/smoke-abandoned-cleanup-orchestrator.php +++ b/tests/smoke-abandoned-cleanup-orchestrator.php @@ -322,4 +322,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); From 469d5b67d3e1b6c13cf2ea0a7353d549991444d8 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:05:08 -0400 Subject: [PATCH 2/2] Fix active no-signal drain lint --- .../WorkspaceAbandonedCleanupOrchestrator.php | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php b/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php index ccf8568c..b1606224 100644 --- a/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php +++ b/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php @@ -178,8 +178,8 @@ public function run( array $input ): array|\WP_Error { $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']; + $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; } } @@ -381,9 +381,9 @@ private function append_active_no_signal_backlog_summary( array &$result, int $l ); if ( is_wp_error($report) ) { $result['remaining_active_no_signal_backlog'] = array( - 'available' => false, - 'reason' => (string) $report->get_error_code(), - 'message' => $report->get_error_message(), + 'available' => false, + 'reason' => (string) $report->get_error_code(), + 'message' => $report->get_error_message(), 'next_commands' => array( sprintf('studio wp datamachine-code workspace worktree active-no-signal-report --limit=%d --offset=0 --format=json', $limit), ), @@ -418,7 +418,7 @@ private function build_active_no_signal_backlog_summary( array $report, int $lim if ( ! is_array($row) ) { continue; } - $reason = (string) ( $row['suggested_action'] ?? 'insufficient_signal' ); + $reason = (string) ( $row['suggested_action'] ?? 'insufficient_signal' ); $buckets[ $reason ] ??= array( 'count' => 0, 'examples' => array(), @@ -441,15 +441,15 @@ private function build_active_no_signal_backlog_summary( array $report, int $lim } return array( - 'available' => true, - 'total_active_no_signal' => $total, - 'sampled' => $sampled, - 'unreviewed_count' => max(0, $total - $sampled), - 'by_actionable_reason' => $buckets, - 'counts_scope' => 'bounded_post_drain_sample_only', - 'limitation' => 'Counts by actionable reason cover only this bounded post-drain sample; active-no-signal report has pagination but no safe bucket filter, so full per-bucket totals are not scanned by default.', - 'pagination' => $pagination, - 'next_commands' => array_values(array_unique(array_filter($commands))), + 'available' => true, + 'total_active_no_signal' => $total, + 'sampled' => $sampled, + 'unreviewed_count' => max(0, $total - $sampled), + 'by_actionable_reason' => $buckets, + 'counts_scope' => 'bounded_post_drain_sample_only', + 'limitation' => 'Counts by actionable reason cover only this bounded post-drain sample; active-no-signal report has pagination but no safe bucket filter, so full per-bucket totals are not scanned by default.', + 'pagination' => $pagination, + 'next_commands' => array_values(array_unique($commands)), ); } @@ -629,18 +629,18 @@ private function drain_pages( object $ability, array $base_input, bool $apply, ? break; } - if ( $stop_on_no_progress && $apply && $mutation_count <= 0 && $inspected > 0 ) { + 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, + '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;