fix(parallel): stabilize execution state and worktree lifecycle#317
fix(parallel): stabilize execution state and worktree lifecycle#317
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Codecov Report❌ Patch coverage is
❌ Your patch status has failed because the patch coverage (31.74%) is below the target coverage (50.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## main #317 +/- ##
==========================================
+ Coverage 45.00% 46.13% +1.12%
==========================================
Files 98 98
Lines 31020 31308 +288
==========================================
+ Hits 13962 14443 +481
+ Misses 17058 16865 -193
🚀 New features to boost your workflow:
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds commit-count tracking to workers; treats zero-commit merges as failures; exposes parallel-run helpers for local/merged completion tracking and guarded lifecycle (start/reset/shutdown); tightens merge/conflict retry and reset-to-open logic in the parallel executor; enforces active worktree counting. Tests added/updated. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User
participant RunCmd as RunCommand (run.tsx)
participant Executor as ParallelExecutor
participant Worker as Worker
participant Engine as ExecutionEngine
User->>RunCmd: start parallel run
RunCmd->>RunCmd: startParallelExecution() → executionPromise
RunCmd->>Executor: execute()
Executor->>Worker: start worker
Worker->>Worker: reset commitCount
Engine-->>Worker: task:auto-committed event
Worker->>Worker: increment commitCount
Worker->>Executor: emit worker:completed (includes commitCount)
Executor->>Executor: evaluate result (commitCount, success, shouldStop)
alt commitCount < 1 or merge failure
Executor->>Executor: handleMergeFailure(), resetTaskToOpen(taskId)
Executor->>RunCmd: emit parallel:group-completed (failure)
else commitCount >= 1 and success
Executor->>RunCmd: emit parallel:group-completed (success)
end
Executor->>RunCmd: parallel:completed (status)
RunCmd->>RunCmd: applyParallelCompletionState(executorStatus)
RunCmd->>RunCmd: await executionPromise resolution for graceful shutdown
RunCmd->>User: run finished
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
src/commands/run.tsx (2)
1995-2003:gracefulShutdownis not re-entrant — add a guard flag.
process.removeListenerat Line 2164 runs only after the quit promise resolves. A second SIGTERM arriving whilegracefulShutdownis blocked insideawait parallelExecutor.stop()will invokegracefulShutdownconcurrently, potentially callinginterruptHandler.dispose()andrenderer.destroy()twice.♻️ Proposed fix
+ let isShuttingDown = false; const gracefulShutdown = async (): Promise<void> => { + if (isShuttingDown) return; + isShuttingDown = true; if (executionPromise) { // ...Also applies to: 2027-2027, 2159-2164
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/commands/run.tsx` around lines 1995 - 2003, The gracefulShutdown function is not re-entrant and can be invoked concurrently (e.g., by multiple SIGTERM signals), risking double disposal of resources like interruptHandler.dispose() and renderer.destroy(); add a boolean guard (e.g., isShuttingDown) at the top of gracefulShutdown to return early if already running, set it true immediately on entry and false only if you truly want re-entry, and ensure all cleanup steps (parallelExecutor.stop(), await executionPromise, savePersistedSession(currentState), cleanup(), resolveQuitPromise?.()) run only once; also guard any other shutdown entry points that call gracefulShutdown (signals/handlers) to check this flag before invoking and avoid double-calling interruptHandler.dispose() and renderer.destroy().
1996-2002: CaptureexecutionPromisebeforeawait stop()to make intent explicit.
executionPromisecan be set tonullby the.finally()handler instartParallelExecutionwhileawait parallelExecutor.stop()is suspended, turning the subsequentawait executionPromiseintoawait null. Although functionally safe (execution has already unwound at that point), capturing the reference first makes the intent clear and prevents accidental no-ops if the pattern is extended.♻️ Suggested refactor (both sites)
- if (executionPromise) { - await parallelExecutor.stop(); - await executionPromise; - } + const inFlight = executionPromise; + if (inFlight) { + await parallelExecutor.stop(); + await inFlight; + }Also applies to: 2098-2103
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/commands/run.tsx` around lines 1996 - 2002, The code awaits parallelExecutor.stop() and then awaits executionPromise, but executionPromise can be nulled by the .finally() in startParallelExecution while stop() is suspended; capture the current executionPromise reference into a local variable (e.g., const exec = executionPromise) before calling await parallelExecutor.stop(), then await that local (await exec) so the intent is explicit and you avoid awaiting null; apply the same change for the other site mentioned (the block near 2098-2103) and keep the subsequent savePersistedSession(currentState) and cleanup() calls unchanged.tests/commands/run.test.ts (1)
478-493: Missing test case:taskCompleted=falsewith existing entry.The
elsebranch ofupdateCompletedLocallyTaskIdsdeletes the task ID from the set, but only the(true, 0)variant is tested. Adding a(false, N)case (e.g., a retry that didn't complete) would complete the branch coverage.➕ Suggested additional test
+ test('removes stale task ID when task did not complete', () => { + const next = updateCompletedLocallyTaskIds( + new Set<string>(['task-1', 'task-2']), + 'task-1', + false, + 3 + ); + expect(next).toEqual(new Set(['task-2'])); + });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/commands/run.test.ts` around lines 478 - 493, The test suite is missing a case exercising updateCompletedLocallyTaskIds when taskCompleted is false and the task ID already exists; add a test that calls updateCompletedLocallyTaskIds(new Set(['task-1','task-2']), 'task-1', false, anyNumber) and assert the resulting set no longer contains 'task-1' (e.g., equals new Set(['task-2'])), so the branch that deletes on taskCompleted=false is covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/commands/run.tsx`:
- Around line 2098-2103: The onParallelKill handler currently calls
parallelExecutor.stop() without protection; wrap the stop() call in a try/catch
(mirroring gracefulShutdown) so any exception from parallelExecutor.stop() is
caught and does not bubble to the UI, and ensure executionPromise is still
awaited (use finally or await after catch) so the executor is left in a known
state; update the onParallelKill implementation (referencing onParallelKill,
parallelExecutor.stop, executionPromise, and gracefulShutdown) to catch and
handle/log the error and still await executionPromise.
- Around line 1995-2003: The gracefulShutdown function can hang if
parallelExecutor.stop() rejects because the rejection prevents subsequent calls
to savePersistedSession(currentState), cleanup(), and resolveQuitPromise(),
leaving runParallelWithTui awaiting the quit promise; modify gracefulShutdown to
catch and handle errors from parallelExecutor.stop() (and executionPromise) so
that failures do not short-circuit the rest of the shutdown sequence: call await
parallelExecutor.stop() inside a try/catch (or use
Promise.resolve(...).catch(...)), log or store the error, then always proceed to
await savePersistedSession(currentState), await cleanup(), and call
resolveQuitPromise?.(); ensure any thrown errors from awaiting executionPromise
are similarly caught so cleanup and resolveQuitPromise are guaranteed to run.
---
Nitpick comments:
In `@src/commands/run.tsx`:
- Around line 1995-2003: The gracefulShutdown function is not re-entrant and can
be invoked concurrently (e.g., by multiple SIGTERM signals), risking double
disposal of resources like interruptHandler.dispose() and renderer.destroy();
add a boolean guard (e.g., isShuttingDown) at the top of gracefulShutdown to
return early if already running, set it true immediately on entry and false only
if you truly want re-entry, and ensure all cleanup steps
(parallelExecutor.stop(), await executionPromise,
savePersistedSession(currentState), cleanup(), resolveQuitPromise?.()) run only
once; also guard any other shutdown entry points that call gracefulShutdown
(signals/handlers) to check this flag before invoking and avoid double-calling
interruptHandler.dispose() and renderer.destroy().
- Around line 1996-2002: The code awaits parallelExecutor.stop() and then awaits
executionPromise, but executionPromise can be nulled by the .finally() in
startParallelExecution while stop() is suspended; capture the current
executionPromise reference into a local variable (e.g., const exec =
executionPromise) before calling await parallelExecutor.stop(), then await that
local (await exec) so the intent is explicit and you avoid awaiting null; apply
the same change for the other site mentioned (the block near 2098-2103) and keep
the subsequent savePersistedSession(currentState) and cleanup() calls unchanged.
In `@tests/commands/run.test.ts`:
- Around line 478-493: The test suite is missing a case exercising
updateCompletedLocallyTaskIds when taskCompleted is false and the task ID
already exists; add a test that calls updateCompletedLocallyTaskIds(new
Set(['task-1','task-2']), 'task-1', false, anyNumber) and assert the resulting
set no longer contains 'task-1' (e.g., equals new Set(['task-2'])), so the
branch that deletes on taskCompleted=false is covered.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
tests/commands/run.test.ts (1)
463-576: Good coverage for all new helpers — one boundary case missing.All three logical branches of
shouldMarkCompletedLocally, both directions ofupdateCompletedLocallyTaskIds, both executor-status paths ofapplyParallelCompletionState, and the key combinations ofisParallelExecutionCompleteare covered.
applyConflictResolvedTaskTrackingtests useresolutionCount = 0and2, but the guard is< 1, meaningresolutionCount === 1is the exact boundary. Consider adding a test at the boundary to make the contract explicit:✅ Suggested boundary test
+ test('moves task from completed-locally to merged when a single conflict is resolved', () => { + const outcome = applyConflictResolvedTaskTracking( + new Set<string>(['task-1']), + new Set<string>(), + 'task-1', + 1 + ); + + expect(outcome.completedLocallyTaskIds).toEqual(new Set()); + expect(outcome.mergedTaskIds).toEqual(new Set(['task-1'])); + });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/commands/run.test.ts` around lines 463 - 576, Add a boundary test for applyConflictResolvedTaskTracking where resolutionCount === 1: call applyConflictResolvedTaskTracking(new Set(['task-1','task-2']), new Set(['task-3']), 'task-1', 1) and assert that completedLocallyTaskIds equals new Set(['task-2']) and mergedTaskIds equals new Set(['task-3','task-1']), ensuring the "< 1" guard treats 1 as a resolved count; place the test alongside the existing applyConflictResolvedTaskTracking cases and use the same naming pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/commands/run.tsx`:
- Around line 2152-2159: The persisted session status isn't reset when
restarting parallel execution; update resetParallelStateForRestart() or the
onParallelStart handler to set currentState.status = 'running' (and persist that
session immediately) before calling parallelExecutor.reset() /
startParallelExecution() so that subsequent worker:started ->
addActiveTask(currentState, ...) writes a session file with status 'running'
rather than a stale 'interrupted'/'failed'; reference currentState,
resetParallelStateForRestart(), onParallelStart, addActiveTask, worker:started,
parallelExecutor.reset, and startParallelExecution when making the change.
---
Nitpick comments:
In `@tests/commands/run.test.ts`:
- Around line 463-576: Add a boundary test for applyConflictResolvedTaskTracking
where resolutionCount === 1: call applyConflictResolvedTaskTracking(new
Set(['task-1','task-2']), new Set(['task-3']), 'task-1', 1) and assert that
completedLocallyTaskIds equals new Set(['task-2']) and mergedTaskIds equals new
Set(['task-3','task-1']), ensuring the "< 1" guard treats 1 as a resolved count;
place the test alongside the existing applyConflictResolvedTaskTracking cases
and use the same naming pattern.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/commands/run.tsx (1)
3224-3257:⚠️ Potential issue | 🟡 Minor
parallelExecutor.stop()is unguarded in the headless signal handler — mirrors the issue previously fixed in the TUI path.If
stop()rejects at line 3232, the error propagates out of theasyncsignal handler (potentialunhandledRejection), and the task-reset /savePersistedSessionblock that follows is never reached, leaving tasks stuck inin_progress.🐛 Proposed fix
console.log('\n[INFO] [parallel] Received signal, stopping parallel execution...'); - // Stop the parallel executor - await parallelExecutor.stop(); + // Stop the parallel executor (best-effort) + try { + await parallelExecutor.stop(); + } catch { + // Continue cleanup even if stop() fails + } // Wait for execute() to unwind so cleanup (worktrees/branches/tags) completes.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/commands/run.tsx` around lines 3224 - 3257, The signal handler handleParallelSignal currently awaits parallelExecutor.stop() without guarding against rejection, which can abort the handler and skip the task-reset/save steps; wrap the call to parallelExecutor.stop() in a try/catch (or surrounding try/finally) so any rejection is caught and logged but does not prevent the subsequent waits (parallelExecutionPromise), resetting active tasks via tracker.updateTaskStatus, clearing active tasks with clearActiveTasks, setting persistedState.status = 'interrupted', and calling savePersistedSession; keep the existing best-effort catches around tracker.updateTaskStatus and preserve use of parallelExecutionPromise.catch(() => {}) so cleanup always runs even if stop() fails.
🧹 Nitpick comments (3)
tests/engine/execution-engine.test.ts (1)
677-677:(engine as any).rateLimitedAgentsdirectly tests the regression fixAccessing the private
rateLimitedAgentsfield viaas anyis acceptable here since the test is specifically verifying the newclearRateLimitedAgents()call introduced by this PR. Consider adding a package-internal getter (e.g.getRateLimitedAgentsCount(): number) if this field needs broader test coverage in future.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/engine/execution-engine.test.ts` at line 677, The test is reaching into a private field rateLimitedAgents via (engine as any).rateLimitedAgents.size; instead add a package-internal getter on the engine class (e.g. getRateLimitedAgentsCount(): number) that returns rateLimitedAgents.size, implement it alongside clearRateLimitedAgents(), and update the test to assert expect(engine.getRateLimitedAgentsCount()).toBe(0) so the test no longer uses type-unsafe casting.tests/remote/remote.test.ts (1)
436-449:setTimeout(resolve, 0)is fragile for async-chain settlingThe current implementation's
.then(async () => { await resetTasksToOpen(); ... })chain settles within the scheduled macrotask window, so the test passes. However, if an additionalawaitis introduced in the chain (e.g. awaiting thesend()call or an audit log), the test would silently miss assertions. A more robust approach is to flush all pending microtasks explicitly:♻️ Proposed fix
-await new Promise((resolve) => setTimeout(resolve, 0)); +// Flush all pending microtask queues +await Promise.resolve(); +await Promise.resolve(); +await Promise.resolve();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/remote/remote.test.ts` around lines 436 - 449, The test uses await new Promise(resolve => setTimeout(resolve, 0)) which relies on a macrotask and is fragile; replace that with an explicit microtask flush so pending promises in the async chain reliably settle before assertions. Change the wait to an explicit microtask flush (for example: await Promise.resolve(); await Promise.resolve(); or use a shared flushPromises helper that awaits Promise.resolve() twice or process.nextTick) right before the assertions that check on, stop, resetTasksToOpen and ws.send (referencing the existing symbols on, stop, resetTasksToOpen, ws.send and OperationResultMessage) so the async chain is fully settled and the JSON-parsed response assertions are deterministic.tests/commands/run.test.ts (1)
506-535: Consider a narrower type for the partial state stub instead ofas any.The
as anycasts on lines 512 and 527 bypass type-checking for the stub objects. A typed partial avoids masking future type breakages inPersistedSessionState.♻️ Suggested refinement
+import type { PersistedSessionState } from '../../src/session/index.js'; - const state = { + const state: Partial<PersistedSessionState> & Pick<PersistedSessionState, 'status' | 'isPaused' | 'activeTaskIds' | 'updatedAt'> = { status: 'running', isPaused: true, activeTaskIds: ['task-1'], updatedAt: '2026-01-01T00:00:00.000Z', - } as any; + };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/commands/run.test.ts` around lines 506 - 535, The tests use loose `as any` casts for the stubbed session state which bypass TypeScript checks; replace those casts by typing the fixtures as Partial<PersistedSessionState> (or Pick<...> of the required fields) so the test stubs match the real shape—locate the two test cases in tests/commands/run.test.ts that call applyParallelCompletionState and change the state declarations to use Partial<PersistedSessionState> (preserving fields: status, isPaused, activeTaskIds, updatedAt) to keep type safety while still allowing omitted properties.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/remote/server.ts`:
- Around line 855-858: The stop path races with runIteration: engine.stop()
currently returns before the running runIteration resolves, so
resetTasksToOpen([activeTaskId]) can flip a legitimately completed task back to
open; update engine.stop() or the server stop handler to await the agent
execution unwind (the same pattern used in run.tsx) — i.e., have the Engine
expose or track an executionPromise (or similar executor unwind promise) from
runIteration and await that promise in the kill/shutdown path before calling
resetTasksToOpen; ensure you still await updateSessionStatus but only call
engine.resetTasksToOpen([activeTaskId]) after executionPromise has resolved.
---
Outside diff comments:
In `@src/commands/run.tsx`:
- Around line 3224-3257: The signal handler handleParallelSignal currently
awaits parallelExecutor.stop() without guarding against rejection, which can
abort the handler and skip the task-reset/save steps; wrap the call to
parallelExecutor.stop() in a try/catch (or surrounding try/finally) so any
rejection is caught and logged but does not prevent the subsequent waits
(parallelExecutionPromise), resetting active tasks via tracker.updateTaskStatus,
clearing active tasks with clearActiveTasks, setting persistedState.status =
'interrupted', and calling savePersistedSession; keep the existing best-effort
catches around tracker.updateTaskStatus and preserve use of
parallelExecutionPromise.catch(() => {}) so cleanup always runs even if stop()
fails.
---
Nitpick comments:
In `@tests/commands/run.test.ts`:
- Around line 506-535: The tests use loose `as any` casts for the stubbed
session state which bypass TypeScript checks; replace those casts by typing the
fixtures as Partial<PersistedSessionState> (or Pick<...> of the required fields)
so the test stubs match the real shape—locate the two test cases in
tests/commands/run.test.ts that call applyParallelCompletionState and change the
state declarations to use Partial<PersistedSessionState> (preserving fields:
status, isPaused, activeTaskIds, updatedAt) to keep type safety while still
allowing omitted properties.
In `@tests/engine/execution-engine.test.ts`:
- Line 677: The test is reaching into a private field rateLimitedAgents via
(engine as any).rateLimitedAgents.size; instead add a package-internal getter on
the engine class (e.g. getRateLimitedAgentsCount(): number) that returns
rateLimitedAgents.size, implement it alongside clearRateLimitedAgents(), and
update the test to assert expect(engine.getRateLimitedAgentsCount()).toBe(0) so
the test no longer uses type-unsafe casting.
In `@tests/remote/remote.test.ts`:
- Around line 436-449: The test uses await new Promise(resolve =>
setTimeout(resolve, 0)) which relies on a macrotask and is fragile; replace that
with an explicit microtask flush so pending promises in the async chain reliably
settle before assertions. Change the wait to an explicit microtask flush (for
example: await Promise.resolve(); await Promise.resolve(); or use a shared
flushPromises helper that awaits Promise.resolve() twice or process.nextTick)
right before the assertions that check on, stop, resetTasksToOpen and ws.send
(referencing the existing symbols on, stop, resetTasksToOpen, ws.send and
OperationResultMessage) so the async chain is fully settled and the JSON-parsed
response assertions are deterministic.
| engine.stop().then(async () => { | ||
| if (activeTaskId) { | ||
| await engine.resetTasksToOpen([activeTaskId]); | ||
| } |
There was a problem hiding this comment.
stop() does not await runIteration unwind before resetTasksToOpen
engine.stop() sends an interrupt signal and awaits only updateSessionStatus — it returns before runIteration has finished processing the agent's final output. This creates the same stop-before-unwind race that the PR explicitly fixes in run.tsx:
stop()resolves →resetTasksToOpen(['task-123'])sets the task to'open'runIterationis still running, waiting onhandle.promise- If the agent had already emitted
<promise>COMPLETE</promise>before the interrupt arrived,taskCompleted = trueandtracker.completeTaskruns — resetting a genuinely completed task back to'open'
The fix applied in run.tsx (tracking an executionPromise and awaiting executor unwind in the kill/shutdown path) should also be applied here so that resetTasksToOpen is called only after runIteration has fully resolved.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/remote/server.ts` around lines 855 - 858, The stop path races with
runIteration: engine.stop() currently returns before the running runIteration
resolves, so resetTasksToOpen([activeTaskId]) can flip a legitimately completed
task back to open; update engine.stop() or the server stop handler to await the
agent execution unwind (the same pattern used in run.tsx) — i.e., have the
Engine expose or track an executionPromise (or similar executor unwind promise)
from runIteration and await that promise in the kill/shutdown path before
calling resetTasksToOpen; ensure you still await updateSessionStatus but only
call engine.resetTasksToOpen([activeTaskId]) after executionPromise has
resolved.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/commands/run.tsx (2)
3222-3271: Headless signal handler doesn't guard againststop()rejection.Unlike the TUI path (which wraps
stop()in try/catch), the headlesshandleParallelSignalcallsawait parallelExecutor.stop()directly at Line 3231. Since this is an async signal handler, an uncaught rejection would surface as an unhandled promise rejection at the process level. The risk is low (the currentstop()implementation usesPromise.allSettled), but it's inconsistent with the TUI path's defensive pattern.🛡️ Wrap stop() for consistency with the TUI path
console.log('\n[INFO] [parallel] Received signal, stopping parallel execution...'); // Stop the parallel executor - await parallelExecutor.stop(); + try { + await parallelExecutor.stop(); + } catch { + // Best-effort stop; proceed with cleanup regardless + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/commands/run.tsx` around lines 3222 - 3271, The headless signal handler handleParallelSignal calls await parallelExecutor.stop() without catching rejections; wrap that call in a try/catch (same defensive pattern used in the TUI path) so any thrown error from parallelExecutor.stop() is caught and handled (e.g., log at info/error or ignore) to prevent unhandled promise rejections, then continue the existing cleanup (awaiting parallelExecutionPromise, resetting tasks, saving state); update references to parallelExecutor.stop() and handleParallelSignal accordingly.
129-140:_commitCountis accepted but unused — intentional design seam?The parameter is currently ignored (the function returns
taskCompletedverbatim). The PR objectives specifically call out "tasks could be marked completed locally when no commit was produced" as a root cause. The actual fix lives in the executor (zero-commit tasks are treated as merge failures there), so this function serves as a UI-level extension point. The intent is clear, but the unused parameter could confuse future contributors.Consider adding a brief inline comment explaining why the parameter exists but is unused, e.g.
// commitCount reserved for future gating; merge outcome is tracked separately.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/commands/run.tsx` around lines 129 - 140, The parameter _commitCount in shouldMarkCompletedLocally is intentionally unused but confusing; add a brief inline comment inside the function (near the parameter usage) explaining that commit count is reserved for future UI gating and merge success/failure is tracked separately by merge events, e.g., note that zero-commit handling happens in the executor and this function is an extension point, so keep returning taskCompleted while documenting the reason for the unused _commitCount.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@src/commands/run.tsx`:
- Around line 2203-2218: No code changes needed: the onParallelStart handler now
sets currentState.status = 'running', clears paused flags, persists via
savePersistedSession, resets parallelExecutor via parallelExecutor.reset(), and
then calls startParallelExecution(), which resolves the prior race where a crash
could leave a session marked 'interrupted'; leave the logic in onParallelStart,
resetParallelStateForRestart, savePersistedSession, parallelExecutor.reset, and
startParallelExecution as-is.
---
Nitpick comments:
In `@src/commands/run.tsx`:
- Around line 3222-3271: The headless signal handler handleParallelSignal calls
await parallelExecutor.stop() without catching rejections; wrap that call in a
try/catch (same defensive pattern used in the TUI path) so any thrown error from
parallelExecutor.stop() is caught and handled (e.g., log at info/error or
ignore) to prevent unhandled promise rejections, then continue the existing
cleanup (awaiting parallelExecutionPromise, resetting tasks, saving state);
update references to parallelExecutor.stop() and handleParallelSignal
accordingly.
- Around line 129-140: The parameter _commitCount in shouldMarkCompletedLocally
is intentionally unused but confusing; add a brief inline comment inside the
function (near the parameter usage) explaining that commit count is reserved for
future UI gating and merge success/failure is tracked separately by merge
events, e.g., note that zero-commit handling happens in the executor and this
function is an extension point, so keep returning taskCompleted while
documenting the reason for the unused _commitCount.
Summary
This PR fixes multiple failure modes in parallel execution that could leave tasks/workers/session state inconsistent and break the expected default parallel workflow.
Closes #310.
Root Causes Identified
maxWorktrees, eventually blocking new workers.completedLocally: tasks could be marked completed locally even when no commit was produced (for example auto-commit skipped), causing downstream merge/preflight failures.execute()unwound, risking incomplete executor cleanup.open, leaving them stuckin_progress.Changes
src/parallel/worktree-manager.tsmaxWorktreesusing active worktrees only (not all historical entries).src/parallel/worktree-manager.test.tscleanupAll().src/parallel/worker.tscommitCounttracking fromtask:auto-committedevents.commitCountandlastCommitShaat run start to avoid stale run-to-run state.commitCounton success/error/cancel worker results.src/parallel/index.tsopen(best effort) so they can be retried and do not remain stuck.src/commands/run.tsxshouldMarkCompletedLocally(taskCompleted, commitCount)and used it to gate local-completion UI state.completedLocallytask markers when conditions are not met.executionPromiseand used it in kill/shutdown to wait for executor unwind/cleanup.parallel:completedhandling to mark session ascompletedonly when executor status is truly completed; otherwise markinterrupted.tests/commands/run.test.tsshouldMarkCompletedLocallybehavior.Verification
bun run typecheckbun run buildbun test tests/commands/run.test.ts src/parallel/worktree-manager.test.tsUBS_MAX_DIR_SIZE_MB=0 ubs --diff --only=js(reports baseline findings in existing large files; no new issue specific to this patch)Impact
These fixes make the default parallel mode more reliable under long-running and restart-heavy workflows by keeping worker/task/worktree/session state aligned with actual mergeable outcomes.
Summary by CodeRabbit
New Features
Bug Fixes
Performance Improvements
Tests