From 95fe26a4160f59ccbcca44dd7bdcb5dda6a1cc07 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 10 Jun 2026 08:36:28 -0400 Subject: [PATCH 1/2] fix: harden cleanup apply resumability --- inc/Cleanup/CleanupRemainingWorkSummary.php | 43 ++++++-- inc/Cli/Commands/WorkspaceCommand.php | 38 ++++++- inc/Workspace/CleanupRunService.php | 116 +++++++++++++++++++- tests/smoke-cleanup-run-storage.php | 26 ++++- 4 files changed, 203 insertions(+), 20 deletions(-) diff --git a/inc/Cleanup/CleanupRemainingWorkSummary.php b/inc/Cleanup/CleanupRemainingWorkSummary.php index dd209197..58a5e3b7 100644 --- a/inc/Cleanup/CleanupRemainingWorkSummary.php +++ b/inc/Cleanup/CleanupRemainingWorkSummary.php @@ -50,7 +50,7 @@ public static function from_items( array $items ): array { if ( 'artifact_cleanup' === $type && 'applied' !== $status ) { $summary['remaining_reclaimable_artifact_bytes'] += self::row_bytes($row, array( 'artifact_size_bytes', 'size_bytes' )); } - if ( 'worktree_removal' === $type && in_array($status, array( 'pending', 'failed' ), true) ) { + if ( 'worktree_removal' === $type && in_array($status, array( 'pending', 'failed', 'applying' ), true) ) { ++$summary['remaining_safely_removable_worktrees']; } } @@ -181,18 +181,22 @@ private static function recommended_commands( array $summary ): array { $commands = array(); if ( (int) $summary['remaining_reclaimable_artifact_bytes'] > 0 ) { $commands[] = array( - 'bucket' => 'remaining_artifacts', - 'command' => 'studio wp datamachine-code workspace cleanup run --mode=artifacts --dry-run --format=json', - 'apply' => 'studio wp datamachine-code workspace cleanup run --mode=artifacts', - 'destructive' => false, + 'bucket' => 'remaining_artifacts', + 'command' => 'studio wp datamachine-code workspace cleanup run --mode=artifacts --dry-run --format=json', + 'apply' => 'studio wp datamachine-code workspace cleanup run --mode=artifacts', + 'destructive' => false, + 'apply_destructive' => true, + 'why' => 'Preview remaining artifact cleanup first; the apply command removes revalidated artifacts.', ); } if ( (int) $summary['remaining_safely_removable_worktrees'] > 0 ) { $commands[] = array( - 'bucket' => 'remaining_worktrees', - 'command' => 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25', - 'apply' => 'studio wp datamachine-code workspace cleanup run --mode=retention', - 'destructive' => false, + 'bucket' => 'remaining_worktrees', + 'command' => 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25', + 'apply' => 'studio wp datamachine-code workspace cleanup run --mode=retention', + 'destructive' => false, + 'apply_destructive' => true, + 'why' => 'Preview cleanup-eligible worktrees first; the apply command removes revalidated worktrees.', ); } foreach ( array_keys( (array) $summary['skipped_by_reason'] ) as $reason ) { @@ -225,9 +229,24 @@ private static function command_for_reason( string $reason, string $bucket ): ar }; return array( - 'bucket' => $bucket . ':' . $reason, - 'command' => $command, - 'destructive' => false, + 'bucket' => $bucket . ':' . $reason, + 'command' => $command, + 'destructive' => false, + 'apply_destructive' => false, + 'why' => self::reason_remediation($reason), ); } + + private static function reason_remediation( string $reason ): string { + return match ( $reason ) { + 'dirty_worktree' => 'Inspect dirty files before applying cleanup; artifact-only dirt may be removable through artifact cleanup, source dirt needs review.', + 'artifact_plan_mismatch', 'plan_mismatch' => 'Regenerate a fresh plan because the saved row no longer matches current filesystem or branch state.', + 'artifact_plan_not_current', 'artifact_already_removed' => 'Regenerate artifact cleanup evidence; the saved artifact row is no longer a current candidate.', + 'needs_metadata_reconcile' => 'Run metadata reconciliation so DMC can classify the worktree without a full cleanup scan.', + 'lifecycle_reconciliation_candidate' => 'Run lifecycle reconciliation to collect PR/merge signals before emitting removal rows.', + 'unpushed_commits' => 'Push, merge, or intentionally abandon commits before retrying cleanup.', + 'probe_timeout' => 'Retry the review path with a smaller bounded page or investigate the git probe timeout.', + default => 'Run the review command to refresh evidence before applying cleanup.', + }; + } } diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index b29833a1..ecc937f2 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -767,6 +767,9 @@ private function render_cleanup_control_result( array $result, array $assoc_args WP_CLI::log(sprintf('%s: %s', ucfirst(str_replace('_', ' ', $key)), (string) $result[ $key ])); } } + if ( ! empty($result['progress']) && is_array($result['progress']) ) { + $this->render_cleanup_progress_summary( (array) $result['progress']); + } if ( ! empty($result['remaining_work_summary']) && is_array($result['remaining_work_summary']) ) { $this->render_cleanup_remaining_work_summary( (array) $result['remaining_work_summary']); } @@ -810,12 +813,41 @@ private function render_cleanup_remaining_work_summary( array $summary ): void { WP_CLI::log('Recommended next commands:'); $rows = array_map( fn( $row ) => array( - 'bucket' => is_array($row) ? (string) ( $row['bucket'] ?? '' ) : '', - 'command' => is_array($row) ? (string) ( $row['command'] ?? '' ) : '', + 'bucket' => is_array($row) ? (string) ( $row['bucket'] ?? '' ) : '', + 'review_command' => is_array($row) ? (string) ( $row['command'] ?? '' ) : '', + 'apply_command' => is_array($row) ? (string) ( $row['apply'] ?? '' ) : '', + 'apply_destructive' => is_array($row) && ! empty($row['apply_destructive']) ? 'yes' : 'no', ), array_slice($commands, 0, 10) ); - $this->format_items($rows, array( 'bucket', 'command' ), array( 'format' => 'table' ), 'bucket'); + $this->format_items($rows, array( 'bucket', 'review_command', 'apply_command', 'apply_destructive' ), array( 'format' => 'table' ), 'bucket'); + } + } + + private function render_cleanup_progress_summary( array $progress ): void { + WP_CLI::log(''); + WP_CLI::log('Progress:'); + $this->format_items( + array( + array( + 'metric' => 'applying_rows', + 'value' => (int) ( $progress['applying_rows'] ?? 0 ), + ), + array( + 'metric' => 'pending_or_failed', + 'value' => (int) ( $progress['pending_or_failed'] ?? 0 ), + ), + array( + 'metric' => 'resumable', + 'value' => ! empty($progress['resumable']) ? 'yes' : 'no', + ), + ), + array( 'metric', 'value' ), + array( 'format' => 'table' ), + 'metric' + ); + if ( ! empty($progress['note']) ) { + WP_CLI::log((string) $progress['note']); } } diff --git a/inc/Workspace/CleanupRunService.php b/inc/Workspace/CleanupRunService.php index ea8113b3..0f5556e9 100644 --- a/inc/Workspace/CleanupRunService.php +++ b/inc/Workspace/CleanupRunService.php @@ -16,6 +16,7 @@ class CleanupRunService { private const DEFAULT_APPLY_LIMIT = 25; private const MAX_APPLY_LIMIT = 100; + private const WORKTREE_APPLY_BATCH_LIMIT = 1; @@ -104,6 +105,7 @@ public function apply( string $run_id, array $opts = array() ): array|\WP_Error $artifact_batch = array_slice($artifact_rows, 0, $limit); $processed_rows += count($artifact_batch); $batch_type = 'artifact_cleanup'; + $this->mark_batch_applying($artifact_batch, $run_id, $batch_type, $limit, $remaining_rows); $results['artifact_cleanup'] = $this->workspace->worktree_cleanup_artifacts( array( 'apply_plan' => array( 'candidates' => array_map(fn( $item ) => $item['evidence'], $artifact_batch) ), @@ -116,9 +118,10 @@ public function apply( string $run_id, array $opts = array() ): array|\WP_Error $remaining_capacity = max(0, $limit - $processed_rows); if ( $remaining_capacity > 0 && array() !== $worktree_rows ) { - $worktree_batch = array_slice($worktree_rows, 0, $remaining_capacity); + $worktree_batch = array_slice($worktree_rows, 0, min($remaining_capacity, self::WORKTREE_APPLY_BATCH_LIMIT)); $processed_rows += count($worktree_batch); $batch_type = '' === $batch_type ? 'worktree_removal' : 'mixed'; + $this->mark_batch_applying($worktree_batch, $run_id, $batch_type, $limit, $remaining_rows); $results['worktree_removal'] = $this->workspace->worktree_cleanup_merged( array( 'apply_plan' => array( 'candidates' => array_map(fn( $item ) => $item['evidence'], $worktree_batch) ), @@ -195,13 +198,15 @@ public function status( string $run_id ): array|\WP_Error { $summary['items_by_status'][ $status ] = ( $summary['items_by_status'][ $status ] ?? 0 ) + 1; $summary['items_by_type'][ $type ] = ( $summary['items_by_type'][ $type ] ?? 0 ) + 1; $summary['bytes_reclaimed'] += max(0, (int) ( $item['bytes_reclaimed'] ?? 0 )); - if ( in_array($status, array( 'pending', 'failed' ), true) ) { + if ( in_array($status, array( 'pending', 'failed', 'applying' ), true) ) { ++$summary['pending_or_failed']; } } ksort($summary['items_by_status']); ksort($summary['items_by_type']); + $progress = $this->run_progress($run, $items, $summary); + return array( 'success' => true, 'state' => (string) ( $run['status'] ?? 'unknown' ), @@ -210,7 +215,8 @@ public function status( string $run_id ): array|\WP_Error { 'mode' => $run['mode'] ?? '', 'run' => $run, 'summary' => $summary, - 'remaining_work_summary' => CleanupRemainingWorkSummary::from_items($items), + 'progress' => $progress, + 'remaining_work_summary' => $this->remaining_work_summary($run_id, $items, $progress), ); } @@ -285,7 +291,109 @@ private function plan_items( array $plan ): array { } private function pending_rows_of_type( array $items, string $type ): array { - return array_values(array_filter($items, fn( $item ) => (string) ( $item['item_type'] ?? '' ) === $type && in_array( (string) ( $item['status'] ?? '' ), array( 'pending', 'failed' ), true))); + return array_values(array_filter($items, fn( $item ) => (string) ( $item['item_type'] ?? '' ) === $type && in_array( (string) ( $item['status'] ?? '' ), array( 'pending', 'failed', 'applying' ), true))); + } + + /** + * Mark rows as in-progress before invoking destructive cleanup so interrupted + * operator runs leave a visible, resumable checkpoint instead of silent state. + * + * @param array> $items Batch rows. + * @param string $run_id Run ID. + * @param string $batch_type Batch type label. + * @param int $limit Requested apply limit. + * @param int $remaining_rows Rows remaining before this batch. + */ + private function mark_batch_applying( array $items, string $run_id, string $batch_type, int $limit, int $remaining_rows ): void { + $started_at = gmdate('Y-m-d H:i:s'); + foreach ( $items as $item ) { + $this->repository->update_item( + (int) $item['id'], + array( + 'status' => 'applying', + 'evidence' => array_merge( + (array) ( $item['evidence'] ?? array() ), + array( + 'applying_started_at' => $started_at, + 'applying_batch_type' => $batch_type, + ) + ), + ) + ); + } + + $this->repository->update_run( + $run_id, + array( + 'summary' => array( + 'applying_batch' => array( + 'type' => $batch_type, + 'limit' => $limit, + 'row_count' => count($items), + 'remaining_before' => $remaining_rows, + 'started_at' => $started_at, + ), + ), + ) + ); + } + + /** + * Build operator progress metadata for status/evidence output. + * + * @param array $run Run row. + * @param array> $items Item rows. + * @param array $summary Aggregate summary. + * @return array + */ + private function run_progress( array $run, array $items, array $summary ): array { + $applying = array_values(array_filter($items, fn( $item ) => 'applying' === (string) ( $item['status'] ?? '' ))); + $examples = array_slice(array_map(fn( $item ) => array( + 'handle' => (string) ( $item['handle'] ?? '' ), + 'type' => (string) ( $item['item_type'] ?? '' ), + ), $applying), 0, 3); + + $started_at = (string) ( $run['started_at'] ?? '' ); + $age = '' !== $started_at ? max(0, time() - strtotime($started_at)) : 0; + $run_status = (string) ( $run['status'] ?? '' ); + $resumable = (int) ( $summary['pending_or_failed'] ?? 0 ) > 0 && in_array($run_status, array( 'applying', 'needs_resume' ), true); + + return array( + 'applying_rows' => count($applying), + 'applying_examples' => $examples, + 'pending_or_failed' => (int) ( $summary['pending_or_failed'] ?? 0 ), + 'started_at' => $started_at, + 'age_seconds' => $age, + 'resumable' => $resumable, + 'note' => count($applying) > 0 ? 'Rows marked applying are safe to retry with workspace cleanup resume if the previous apply process was interrupted.' : '', + ); + } + + /** + * Build remaining-work summary and prepend the current run resume command. + * + * @param string $run_id Run ID. + * @param array> $items Item rows. + * @param array $progress Progress metadata. + * @return array + */ + private function remaining_work_summary( string $run_id, array $items, array $progress ): array { + $summary = CleanupRemainingWorkSummary::from_items($items); + if ( ! empty($progress['resumable']) ) { + array_unshift( + $summary['recommended_commands'], + array( + 'bucket' => 'current_run_resume', + 'command' => sprintf('studio wp datamachine-code workspace cleanup status %s --format=json', $run_id), + 'apply' => sprintf('studio wp datamachine-code workspace cleanup resume %s --limit=%d', $run_id, self::DEFAULT_APPLY_LIMIT), + 'destructive' => false, + 'apply_destructive' => true, + 'why' => 'Resume the reviewed DB-backed cleanup run from persisted pending/failed/applying rows.', + ) + ); + } + + return $summary; } private function apply_limit( array $opts ): int { diff --git a/tests/smoke-cleanup-run-storage.php b/tests/smoke-cleanup-run-storage.php index 43ee1d40..bf0085a1 100644 --- a/tests/smoke-cleanup-run-storage.php +++ b/tests/smoke-cleanup-run-storage.php @@ -226,13 +226,15 @@ function datamachine_code_cleanup_run_assert( bool $condition, string $message ) datamachine_code_cleanup_run_assert(3 === count($status['remaining_work_summary']['blocked_resolvers_by_reason']['needs_metadata_reconcile']['examples'] ?? array()), 'blocked resolver examples are truncated'); datamachine_code_cleanup_run_assert(24 === (int) ( $status['remaining_work_summary']['remaining_reclaimable_artifact_bytes'] ?? 0 ), 'status reports remaining reclaimable artifact bytes'); datamachine_code_cleanup_run_assert(2 === (int) ( $status['remaining_work_summary']['remaining_safely_removable_worktrees'] ?? 0 ), 'status reports remaining safely removable worktrees'); +datamachine_code_cleanup_run_assert(false === (bool) ( $status['progress']['resumable'] ?? true ), 'planned status is not resumable before apply starts'); $apply = $service->apply($plan['run_id']); datamachine_code_cleanup_run_assert(! is_wp_error($apply), 'apply succeeds'); +datamachine_code_cleanup_run_assert('needs_resume' === (string) ( $apply['status'] ?? '' ), 'apply pauses after bounded worktree-removal batch'); $evidence = $service->evidence($plan['run_id']); datamachine_code_cleanup_run_assert(2 === (int) ( $evidence['summary']['items_by_status']['applied'] ?? 0 ), 'evidence reflects applied rows'); -datamachine_code_cleanup_run_assert(2 === (int) ( $evidence['summary']['items_by_status']['skipped'] ?? 0 ), 'evidence reflects skipped rows'); +datamachine_code_cleanup_run_assert(1 === (int) ( $evidence['summary']['items_by_status']['skipped'] ?? 0 ), 'evidence reflects skipped rows from first bounded batch'); datamachine_code_cleanup_run_assert(35 === (int) ( $evidence['summary']['bytes_reclaimed'] ?? 0 ), 'evidence aggregates reclaimed bytes'); datamachine_code_cleanup_run_assert(1 === (int) ( $evidence['remaining_work_summary']['applied_by_type']['artifact_cleanup']['count'] ?? 0 ), 'evidence groups applied artifact rows'); datamachine_code_cleanup_run_assert(15 === (int) ( $evidence['remaining_work_summary']['applied_by_type']['artifact_cleanup']['bytes_reclaimed'] ?? 0 ), 'evidence reports artifact bytes reclaimed'); @@ -240,6 +242,10 @@ function datamachine_code_cleanup_run_assert( bool $condition, string $message ) datamachine_code_cleanup_run_assert('demo@stale-artifact' === (string) ( $evidence['remaining_work_summary']['skipped_by_reason']['plan_mismatch']['examples'][0]['handle'] ?? '' ), 'skipped examples include representative handle'); datamachine_code_cleanup_run_assert(4 === (int) ( $evidence['remaining_work_summary']['blocked_resolvers_by_reason']['needs_metadata_reconcile']['count'] ?? 0 ), 'evidence keeps blocked resolver bucket'); datamachine_code_cleanup_run_assert(str_contains((string) wp_json_encode($evidence['remaining_work_summary']['recommended_commands']), 'workspace worktree reconcile-metadata'), 'summary recommends next DMC commands'); +datamachine_code_cleanup_run_assert(str_contains((string) wp_json_encode($evidence['remaining_work_summary']['recommended_commands']), 'apply_destructive'), 'summary labels destructive apply commands separately from review commands'); + +$done = $service->resume($plan['run_id']); +datamachine_code_cleanup_run_assert('completed' === (string) ( $done['status'] ?? '' ), 'resume completes final bounded worktree-removal batch'); $bounded_workspace = new DataMachineCodeCleanupRunFakeWorkspace(); $bounded_service = new \DataMachineCode\Workspace\CleanupRunService($repo, $bounded_workspace); @@ -250,9 +256,27 @@ function datamachine_code_cleanup_run_assert( bool $condition, string $message ) datamachine_code_cleanup_run_assert('needs_resume' === (string) ( $bounded_apply['status'] ?? '' ), 'bounded apply pauses when rows remain'); datamachine_code_cleanup_run_assert(1 === (int) ( $bounded_apply['batch']['processed_rows'] ?? 0 ), 'bounded apply processes one row'); datamachine_code_cleanup_run_assert(str_contains((string) ( $bounded_apply['next']['resume_command'] ?? '' ), 'workspace cleanup resume'), 'bounded apply returns resume command'); +datamachine_code_cleanup_run_assert(str_contains((string) wp_json_encode($bounded_apply['remaining_work_summary']['recommended_commands'] ?? array()), 'current_run_resume'), 'bounded apply recommends resuming the current reviewed run'); datamachine_code_cleanup_run_assert(1 === count($bounded_workspace->artifact_calls), 'bounded apply only runs artifact cleanup first'); datamachine_code_cleanup_run_assert(0 === count($bounded_workspace->worktree_calls), 'bounded apply defers worktree cleanup until artifacts drain'); +$applying_repo = new \DataMachineCode\Storage\CleanupRunRepository(); +$applying_plan = ( new \DataMachineCode\Workspace\CleanupRunService($applying_repo, new DataMachineCodeCleanupRunFakeWorkspace()) )->plan(array( 'mode' => 'retention' )); +datamachine_code_cleanup_run_assert(! is_wp_error($applying_plan), 'applying status plan succeeds'); +$GLOBALS['wpdb']->runs[ $applying_plan['run_id'] ]['status'] = 'applying'; +$GLOBALS['wpdb']->runs[ $applying_plan['run_id'] ]['started_at'] = gmdate('Y-m-d H:i:s', time() - 120); +foreach ( $GLOBALS['wpdb']->items as &$item ) { + if ((string) ( $item['run_id'] ?? '' ) === (string) $applying_plan['run_id'] && 'worktree_removal' === (string) ( $item['item_type'] ?? '' ) ) { + $item['status'] = 'applying'; + break; + } +} +unset($item); +$applying_status = ( new \DataMachineCode\Workspace\CleanupRunService($applying_repo, new DataMachineCodeCleanupRunFakeWorkspace()) )->status((string) $applying_plan['run_id']); +datamachine_code_cleanup_run_assert(1 === (int) ( $applying_status['summary']['items_by_status']['applying'] ?? 0 ), 'status reports interrupted applying rows'); +datamachine_code_cleanup_run_assert(true === (bool) ( $applying_status['progress']['resumable'] ?? false ), 'applying rows are resumable'); +datamachine_code_cleanup_run_assert(2 === (int) ( $applying_status['remaining_work_summary']['remaining_safely_removable_worktrees'] ?? 0 ), 'applying worktree rows still count as remaining removable worktrees'); + $bounded_service->resume($bounded_plan['run_id'], array( 'limit' => 1 )); $bounded_service->resume($bounded_plan['run_id'], array( 'limit' => 1 )); $bounded_done = $bounded_service->resume($bounded_plan['run_id'], array( 'limit' => 1 )); From 08080789246580844f129cf53a1c833e4d9e91eb Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 10 Jun 2026 08:47:22 -0400 Subject: [PATCH 2/2] fix: satisfy cleanup lint --- inc/Cleanup/CleanupRemainingWorkSummary.php | 4 ---- inc/Cli/Commands/WorkspaceCommand.php | 2 +- inc/Workspace/CleanupRunService.php | 16 ++++++++-------- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/inc/Cleanup/CleanupRemainingWorkSummary.php b/inc/Cleanup/CleanupRemainingWorkSummary.php index 58a5e3b7..58edfcaa 100644 --- a/inc/Cleanup/CleanupRemainingWorkSummary.php +++ b/inc/Cleanup/CleanupRemainingWorkSummary.php @@ -25,10 +25,6 @@ public static function from_items( array $items ): array { $summary = self::empty_summary(); foreach ( $items as $item ) { - if ( ! is_array($item) ) { - continue; - } - $type = (string) ( $item['item_type'] ?? 'unknown' ); $status = (string) ( $item['status'] ?? 'unknown' ); $evidence = (array) ( $item['evidence'] ?? array() ); diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index ecc937f2..6ff2bbff 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -847,7 +847,7 @@ private function render_cleanup_progress_summary( array $progress ): void { 'metric' ); if ( ! empty($progress['note']) ) { - WP_CLI::log((string) $progress['note']); + WP_CLI::log( (string) $progress['note']); } } diff --git a/inc/Workspace/CleanupRunService.php b/inc/Workspace/CleanupRunService.php index 0f5556e9..1f37ca2b 100644 --- a/inc/Workspace/CleanupRunService.php +++ b/inc/Workspace/CleanupRunService.php @@ -14,8 +14,8 @@ class CleanupRunService { - private const DEFAULT_APPLY_LIMIT = 25; - private const MAX_APPLY_LIMIT = 100; + private const DEFAULT_APPLY_LIMIT = 25; + private const MAX_APPLY_LIMIT = 100; private const WORKTREE_APPLY_BATCH_LIMIT = 1; @@ -102,9 +102,9 @@ public function apply( string $run_id, array $opts = array() ): array|\WP_Error $results = array(); if ( array() !== $artifact_rows ) { - $artifact_batch = array_slice($artifact_rows, 0, $limit); - $processed_rows += count($artifact_batch); - $batch_type = 'artifact_cleanup'; + $artifact_batch = array_slice($artifact_rows, 0, $limit); + $processed_rows += count($artifact_batch); + $batch_type = 'artifact_cleanup'; $this->mark_batch_applying($artifact_batch, $run_id, $batch_type, $limit, $remaining_rows); $results['artifact_cleanup'] = $this->workspace->worktree_cleanup_artifacts( array( @@ -118,9 +118,9 @@ public function apply( string $run_id, array $opts = array() ): array|\WP_Error $remaining_capacity = max(0, $limit - $processed_rows); if ( $remaining_capacity > 0 && array() !== $worktree_rows ) { - $worktree_batch = array_slice($worktree_rows, 0, min($remaining_capacity, self::WORKTREE_APPLY_BATCH_LIMIT)); - $processed_rows += count($worktree_batch); - $batch_type = '' === $batch_type ? 'worktree_removal' : 'mixed'; + $worktree_batch = array_slice($worktree_rows, 0, min($remaining_capacity, self::WORKTREE_APPLY_BATCH_LIMIT)); + $processed_rows += count($worktree_batch); + $batch_type = '' === $batch_type ? 'worktree_removal' : 'mixed'; $this->mark_batch_applying($worktree_batch, $run_id, $batch_type, $limit, $remaining_rows); $results['worktree_removal'] = $this->workspace->worktree_cleanup_merged( array(