From b96b1017e1ccbe6dd6d510493eab4ff1a7f2fd15 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:02:51 -0400 Subject: [PATCH 1/3] Fix cleanup apply pagination restart --- .../WorkspaceAbandonedCleanupOrchestrator.php | 8 ++- .../WorkspaceActiveNoSignalCleanup.php | 3 +- .../WorkspaceMetadataReconciliation.php | 43 +++++++++++++- .../smoke-abandoned-cleanup-orchestrator.php | 59 +++++++++++++++++++ 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php b/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php index d28b54de..58b636a7 100644 --- a/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php +++ b/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php @@ -336,6 +336,9 @@ private function stage_incomplete( array $step ): bool { $next_offset = (int) $pagination['next_offset']; $current = (int) ( $pagination['offset'] ?? 0 ); $total = isset($pagination['total']) ? (int) $pagination['total'] : null; + if ( ! empty($pagination['partial']) && $next_offset < $current ) { + return true; + } if ( $next_offset === $current && ! empty($pagination['partial']) ) { return true; } @@ -423,8 +426,9 @@ private function drain_pages( object $ability, array $base_input, bool $apply, ? break; } - $next_offset = isset($pagination['next_offset']) ? (int) $pagination['next_offset'] : null; - if ( null === $next_offset || $next_offset <= $offset ) { + $next_offset = isset($pagination['next_offset']) ? (int) $pagination['next_offset'] : null; + $mutation_count = (int) ( $result['summary']['written'] ?? 0 ) + (int) ( $result['summary']['removed'] ?? 0 ); + if ( null === $next_offset || ( $next_offset <= $offset && ( $mutation_count <= 0 || empty($pagination['partial']) ) ) ) { break; } diff --git a/inc/Workspace/WorkspaceActiveNoSignalCleanup.php b/inc/Workspace/WorkspaceActiveNoSignalCleanup.php index f38b2c03..8c0c9ada 100644 --- a/inc/Workspace/WorkspaceActiveNoSignalCleanup.php +++ b/inc/Workspace/WorkspaceActiveNoSignalCleanup.php @@ -489,8 +489,7 @@ private function build_active_no_signal_apply_pagination( array $pagination, str $limit = (int) ( $pagination['limit'] ?? 25 ); $next_offset = (int) $pagination['next_offset']; if ( ! $dry_run && $written_count > 0 ) { - $current = (int) ( $pagination['offset'] ?? 0 ); - $next_offset = max( $current, $next_offset - $written_count ); + $next_offset = 0; $pagination['next_offset'] = $next_offset; } diff --git a/inc/Workspace/WorkspaceMetadataReconciliation.php b/inc/Workspace/WorkspaceMetadataReconciliation.php index d92ec337..68fc1da6 100644 --- a/inc/Workspace/WorkspaceMetadataReconciliation.php +++ b/inc/Workspace/WorkspaceMetadataReconciliation.php @@ -229,10 +229,12 @@ private function drain_worktree_metadata_reconciliation_budget( int $limit, int $elapsed = microtime(true) - $started_at; $complete = ! empty($last_pagination['complete']); $partial = ! $complete; - $next_command = $partial ? sprintf( + $mutation_count = count($written); + $restart_offset = $mutation_count > 0 ? 0 : (int) ( $last_pagination['next_offset'] ?? $next_offset ); + $next_command = $partial ? sprintf( 'studio wp datamachine-code workspace worktree reconcile-metadata --apply --limit=%d --offset=%d --until-budget=%s --format=json', $limit, - (int) ( $last_pagination['next_offset'] ?? $next_offset ), + $restart_offset, $budget_label ) : null; @@ -244,7 +246,7 @@ private function drain_worktree_metadata_reconciliation_budget( int $limit, int 'scanned' => $scanned, 'partial' => $partial, 'complete' => $complete, - 'next_offset' => $partial ? (int) ( $last_pagination['next_offset'] ?? $next_offset ) : null, + 'next_offset' => $partial ? $restart_offset : null, ); if ( null !== $next_command ) { $pagination['next_command'] = $next_command; @@ -1493,6 +1495,9 @@ private function apply_worktree_metadata_reconciliation_plan( array $plan ): arr if ( isset($plan['pagination']) && is_array($plan['pagination']) ) { $result['pagination'] = $plan['pagination']; + if ( ! empty($plan['direct_apply']) && count($written) > 0 ) { + $result['pagination'] = $this->restart_worktree_metadata_reconciliation_pagination((array) $result['pagination']); + } } if ( isset($plan['evidence']) && is_array($plan['evidence']) ) { $result['evidence'] = array_merge( @@ -1601,6 +1606,38 @@ private function build_worktree_metadata_reconciliation_pagination( int $total, ); } + /** + * Restart follow-up apply pages after writes because the candidate set changed. + * + * @param array $pagination Pagination payload. + * @return array + */ + private function restart_worktree_metadata_reconciliation_pagination( array $pagination ): array { + if ( empty($pagination['partial']) || null === ( $pagination['next_offset'] ?? null ) ) { + return $pagination; + } + + $pagination['next_offset'] = 0; + $pagination['next_command'] = sprintf( + 'studio wp datamachine-code workspace worktree reconcile-metadata --apply --limit=%d --offset=0%s --format=json', + (int) ( $pagination['limit'] ?? self::METADATA_RECONCILE_DEFAULT_LIMIT ), + $this->worktree_metadata_reconciliation_budget_arg((string) ( $pagination['next_command'] ?? '' )) + ); + + return $pagination; + } + + /** + * Extract the existing budget argument from a generated continuation command. + */ + private function worktree_metadata_reconciliation_budget_arg( string $command ): string { + if ( preg_match('/ --until-budget=([^ ]+)/', $command, $matches) ) { + return ' --until-budget=' . $matches[1]; + } + + return ''; + } + /** * Probe the dirty file count for a single worktree path. * diff --git a/tests/smoke-abandoned-cleanup-orchestrator.php b/tests/smoke-abandoned-cleanup-orchestrator.php index c0bbb33c..e9ef92e0 100644 --- a/tests/smoke-abandoned-cleanup-orchestrator.php +++ b/tests/smoke-abandoned-cleanup-orchestrator.php @@ -61,6 +61,33 @@ public function execute( array $input ): array { } } +final class AbandonedCleanupQueuedAbility { + /** @var array> */ + public array $calls = array(); + + /** @param array> $responses */ + public function __construct( private array $responses ) {} + + /** @return array */ + public function execute( array $input ): array { + $this->calls[] = $input; + $response = array_shift($this->responses) ?: array(); + + return array_merge( + array( + 'success' => true, + 'mode' => 'queued', + 'dry_run' => ! empty($input['dry_run']), + 'applied' => empty($input['dry_run']), + 'summary' => array(), + 'pagination' => array( 'complete' => true ), + 'skipped' => array(), + ), + $response + ); + } +} + function abandoned_cleanup_assert( bool $condition, string $label ): void { if ( ! $condition ) { fwrite(STDERR, 'failed: ' . $label . PHP_EOL); @@ -106,4 +133,36 @@ function abandoned_cleanup_assert( bool $condition, string $label ): void { abandoned_cleanup_assert(1 === $result['summary']['blocked'], 'blocked rows are counted'); abandoned_cleanup_assert(! empty($result['next_commands'][0]), 'next apply command is present'); +$reconcile_restart = new AbandonedCleanupQueuedAbility( + array( + array( + 'mode' => 'reconcile_metadata', + 'summary' => array( 'inspected' => 1, 'written' => 1 ), + 'pagination' => array( 'offset' => 0, 'limit' => 10, 'scanned' => 1, 'partial' => true, 'complete' => false, 'next_offset' => 0 ), + ), + array( + 'mode' => 'reconcile_metadata', + 'summary' => array( 'inspected' => 1, 'written' => 0 ), + 'pagination' => array( 'offset' => 0, 'limit' => 10, 'scanned' => 1, 'partial' => false, 'complete' => true, 'next_offset' => null ), + ), + ) +); +$restart_abilities = array( + 'datamachine-code/workspace-worktree-reconcile-metadata' => $reconcile_restart, + 'datamachine-code/workspace-worktree-active-no-signal-finalized-apply' => new AbandonedCleanupFakeAbility('finalized', array(), array( 'complete' => true )), + 'datamachine-code/workspace-worktree-active-no-signal-equivalent-clean-apply' => new AbandonedCleanupFakeAbility('equivalent_clean', array(), array( 'complete' => true )), + '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-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 ) => $restart_abilities[ $name ] ?? null, + static fn(): float => 1000.0 +); +$restart_result = $orchestrator->run(array( 'apply' => true, 'limit' => 10, 'passes' => 1 )); +abandoned_cleanup_assert(! is_wp_error($restart_result), 'restart apply succeeds'); +abandoned_cleanup_assert(2 === count($reconcile_restart->calls), 'mutating reconcile pagination restarts instead of stopping'); +abandoned_cleanup_assert(0 === $reconcile_restart->calls[1]['offset'], 'second reconcile page restarts at offset zero'); + fwrite(STDOUT, 'abandoned cleanup orchestrator smoke passed' . PHP_EOL); From d4c40f6d4ba3886e92b0a8a210e6874a11fdce0e Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:17:56 -0400 Subject: [PATCH 2/3] Fix metadata reconciliation lint --- .../WorkspaceMetadataReconciliation.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/inc/Workspace/WorkspaceMetadataReconciliation.php b/inc/Workspace/WorkspaceMetadataReconciliation.php index 68fc1da6..030a6bcd 100644 --- a/inc/Workspace/WorkspaceMetadataReconciliation.php +++ b/inc/Workspace/WorkspaceMetadataReconciliation.php @@ -226,9 +226,9 @@ private function drain_worktree_metadata_reconciliation_budget( int $limit, int $next_offset = (int) $page_pagination['next_offset']; } while ( ( $budget_seconds - ( microtime(true) - $started_at ) ) > $reserve_seconds ); - $elapsed = microtime(true) - $started_at; - $complete = ! empty($last_pagination['complete']); - $partial = ! $complete; + $elapsed = microtime(true) - $started_at; + $complete = ! empty($last_pagination['complete']); + $partial = ! $complete; $mutation_count = count($written); $restart_offset = $mutation_count > 0 ? 0 : (int) ( $last_pagination['next_offset'] ?? $next_offset ); $next_command = $partial ? sprintf( @@ -646,7 +646,7 @@ private function build_worktree_metadata_reconciliation_row( array $wt, array &$ return $this->build_worktree_metadata_reconciliation_skip($base_row, $diagnostic); } - $resolved_wt = array_merge( + $resolved_wt = array_merge( $wt, array( 'repo' => $repo, @@ -1025,9 +1025,9 @@ private function has_stored_lifecycle_finalizer_context( array $metadata ): bool * @return array{signal:string,reason:string,finalized_state?:string,pr_url?:string}|null */ private function detect_worktree_lifecycle_finalizer_signal( array $wt, array $metadata, array &$github_cache, array &$fetched ): ?array { - $repo = (string) ( $wt['repo'] ?? '' ); - $branch = (string) ( $wt['branch'] ?? '' ); - $pr_signal = $this->detect_stored_pr_merged_signal($metadata, $github_cache); + $repo = (string) ( $wt['repo'] ?? '' ); + $branch = (string) ( $wt['branch'] ?? '' ); + $pr_signal = $this->detect_stored_pr_merged_signal($metadata, $github_cache); if ( null !== $pr_signal ) { return $pr_signal; } @@ -1496,7 +1496,7 @@ private function apply_worktree_metadata_reconciliation_plan( array $plan ): arr if ( isset($plan['pagination']) && is_array($plan['pagination']) ) { $result['pagination'] = $plan['pagination']; if ( ! empty($plan['direct_apply']) && count($written) > 0 ) { - $result['pagination'] = $this->restart_worktree_metadata_reconciliation_pagination((array) $result['pagination']); + $result['pagination'] = $this->restart_worktree_metadata_reconciliation_pagination( (array) $result['pagination'] ); } } if ( isset($plan['evidence']) && is_array($plan['evidence']) ) { @@ -1621,7 +1621,7 @@ private function restart_worktree_metadata_reconciliation_pagination( array $pag $pagination['next_command'] = sprintf( 'studio wp datamachine-code workspace worktree reconcile-metadata --apply --limit=%d --offset=0%s --format=json', (int) ( $pagination['limit'] ?? self::METADATA_RECONCILE_DEFAULT_LIMIT ), - $this->worktree_metadata_reconciliation_budget_arg((string) ( $pagination['next_command'] ?? '' )) + $this->worktree_metadata_reconciliation_budget_arg( (string) ( $pagination['next_command'] ?? '' ) ) ); return $pagination; From d3f412396b9d18dd51a7f34649dad856e7f3b7fb Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:19:14 -0400 Subject: [PATCH 3/3] Align metadata reconciliation assignment --- inc/Workspace/WorkspaceMetadataReconciliation.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/Workspace/WorkspaceMetadataReconciliation.php b/inc/Workspace/WorkspaceMetadataReconciliation.php index 030a6bcd..6b6dad4a 100644 --- a/inc/Workspace/WorkspaceMetadataReconciliation.php +++ b/inc/Workspace/WorkspaceMetadataReconciliation.php @@ -646,7 +646,7 @@ private function build_worktree_metadata_reconciliation_row( array $wt, array &$ return $this->build_worktree_metadata_reconciliation_skip($base_row, $diagnostic); } - $resolved_wt = array_merge( + $resolved_wt = array_merge( $wt, array( 'repo' => $repo,