diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index c89fe07..e7ac2bd 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -3022,6 +3022,9 @@ private function worktree_abandoned_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 ( $next_offset === $current && ! empty($pagination['partial']) ) { + return true; + } if ( null !== $total && $next_offset >= $total ) { return false; } diff --git a/tests/smoke-worktree-cleanup-cli.php b/tests/smoke-worktree-cleanup-cli.php index 9e32b4b..c9134a6 100644 --- a/tests/smoke-worktree-cleanup-cli.php +++ b/tests/smoke-worktree-cleanup-cli.php @@ -461,6 +461,7 @@ class FakeReconcileMetadataAbility { public array $last_input = array(); public array $inputs = array(); + public ?int $stall_at_offset = null; public function execute( array $input ): array { @@ -468,6 +469,27 @@ public function execute( array $input ): array $this->inputs[] = $input; $limit = (int) ( $input['limit'] ?? 25 ); $offset = (int) ( $input['offset'] ?? 0 ); + if ( null !== $this->stall_at_offset && $offset === $this->stall_at_offset ) { + return array( + 'success' => true, + 'mode' => 'metadata_reconcile', + 'dry_run' => ! empty($input['dry_run']), + 'summary' => array( + 'inspected' => 0, + 'proposed' => 0, + 'written' => 0, + ), + 'pagination' => array( + 'total' => $offset + $limit, + 'offset' => $offset, + 'limit' => $limit, + 'scanned' => 0, + 'partial' => true, + 'complete' => false, + 'next_offset' => $offset, + ), + ); + } return array( 'success' => true, 'mode' => 'metadata_reconcile', @@ -1100,6 +1122,17 @@ public function execute( array $input ): array datamachine_code_cleanup_assert($reconcile_call_count === count($reconcile_metadata_ability->inputs), 'abandoned resume skips completed reconciliation stage'); datamachine_code_cleanup_assert(7 === (int) ( $active_finalized_ability->last_input['offset'] ?? -1 ), 'abandoned resume forwards offset to requested classifier stage'); + $reconcile_metadata_ability->stall_at_offset = 90; + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->worktree(array( 'abandoned' ), array( 'apply' => true, 'stage' => 'reconcile', 'offset' => 90, 'limit' => 10, 'passes' => 1, 'until-budget' => '30s', 'format' => 'json' )); + $abandoned_reconcile_resume_json = json_decode(WP_CLI::$logs[0] ?? '', true); + datamachine_code_cleanup_assert(JSON_ERROR_NONE === json_last_error(), 'abandoned same-offset reconcile continuation JSON output parses cleanly'); + datamachine_code_cleanup_assert('reconcile' === ( $abandoned_reconcile_resume_json['continuation']['stage'] ?? '' ), 'abandoned same-offset reconcile continuation keeps reconcile stage'); + datamachine_code_cleanup_assert(90 === (int) ( $abandoned_reconcile_resume_json['continuation']['offset'] ?? -1 ), 'abandoned same-offset reconcile continuation keeps current offset'); + datamachine_code_cleanup_assert(str_contains($abandoned_reconcile_resume_json['continuation']['next_command'] ?? '', '--stage=reconcile --offset=90'), 'abandoned same-offset reconcile continuation emits resumed command'); + $reconcile_metadata_ability->stall_at_offset = null; + $prune_calls_before_preview = $prune_ability->calls; WP_CLI::$logs = array(); WP_CLI::$successes = array();