Skip to content

fix(parallel): stabilize execution state and worktree lifecycle#317

Merged
subsy merged 6 commits intomainfrom
fix/issue-310-parallel-stability
Feb 18, 2026
Merged

fix(parallel): stabilize execution state and worktree lifecycle#317
subsy merged 6 commits intomainfrom
fix/issue-310-parallel-stability

Conversation

@subsy
Copy link
Copy Markdown
Owner

@subsy subsy commented Feb 18, 2026

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

  1. Worktree capacity leak: released worktrees still counted toward maxWorktrees, eventually blocking new workers.
  2. False completedLocally: tasks could be marked completed locally even when no commit was produced (for example auto-commit skipped), causing downstream merge/preflight failures.
  3. Stale restart state: restarting parallel execution from the TUI did not fully reset transient state, leading to mixed UI/session artifacts.
  4. Worker commit accounting reset gap: per-worker commit counters were not reset cleanly across runs.
  5. Shutdown/kill cleanup race: stop paths could return before execute() unwound, risking incomplete executor cleanup.
  6. Merge-failure task status drift: failed merge tasks were not always reset to open, leaving them stuck in_progress.

Changes

src/parallel/worktree-manager.ts

  • Enforced maxWorktrees using active worktrees only (not all historical entries).
  • Added internal active-worktree counting helper.

src/parallel/worktree-manager.test.ts

  • Added regression test proving a new worktree can be acquired after release without requiring cleanupAll().

src/parallel/worker.ts

  • Added persistent commitCount tracking from task:auto-committed events.
  • Reset commitCount and lastCommitSha at run start to avoid stale run-to-run state.
  • Returned actual commitCount on success/error/cancel worker results.

src/parallel/index.ts

  • Treated "task completed with zero commits" as a merge failure path (not mergeable completion).
  • Ensured merge-failed tasks are always reset to open (best effort) so they can be retried and do not remain stuck.

src/commands/run.tsx

  • Added shouldMarkCompletedLocally(taskCompleted, commitCount) and used it to gate local-completion UI state.
  • Cleared stale completedLocally task markers when conditions are not met.
  • Added explicit restart-state reset for parallel TUI execution.
  • Introduced tracked executionPromise and used it in kill/shutdown to wait for executor unwind/cleanup.
  • Updated parallel:completed handling to mark session as completed only when executor status is truly completed; otherwise mark interrupted.
  • Removed SIGTERM listener during teardown to prevent listener accumulation.

tests/commands/run.test.ts

  • Added unit tests for shouldMarkCompletedLocally behavior.

Verification

  • bun run typecheck
  • bun run build
  • bun test tests/commands/run.test.ts src/parallel/worktree-manager.test.ts
  • UBS_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

    • Public helpers added to improve parallel task tracking and to coordinate guarded start/restart/shutdown flows.
  • Bug Fixes

    • Graceful shutdowns and restarts now wait for in‑flight execution before finalising state.
    • Merge failures, timeouts and unresolved conflicts correctly count as failures and reset affected tasks for retry.
    • Conflict-resolution moves tasks between locally‑completed and merged states reliably.
  • Performance Improvements

    • Worktree reuse improved to avoid unnecessary cleanup.
  • Tests

    • Expanded coverage for parallel execution, merge/merge‑failure handling, worker behaviour, engine timeouts and interrupt handling.

@vercel
Copy link
Copy Markdown

vercel bot commented Feb 18, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
ralph-tui Ignored Ignored Preview Feb 18, 2026 5:15pm

Request Review

@codecov
Copy link
Copy Markdown

codecov bot commented Feb 18, 2026

Codecov Report

❌ Patch coverage is 31.74603% with 258 lines in your changes missing coverage. Please review.
✅ Project coverage is 46.13%. Comparing base (3bafac6) to head (2eac118).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
src/commands/run.tsx 19.11% 237 Missing ⚠️
src/parallel/index.ts 71.42% 16 Missing ⚠️
src/engine/index.ts 40.00% 3 Missing ⚠️
src/remote/server.ts 75.00% 2 Missing ⚠️

❌ 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

Impacted file tree graph

@@            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     
Files with missing lines Coverage Δ
src/parallel/worker.ts 64.98% <100.00%> (+15.76%) ⬆️
src/parallel/worktree-manager.ts 55.76% <100.00%> (+0.95%) ⬆️
src/remote/server.ts 4.82% <75.00%> (+2.43%) ⬆️
src/engine/index.ts 58.57% <40.00%> (+7.68%) ⬆️
src/parallel/index.ts 41.01% <71.42%> (+24.76%) ⬆️
src/commands/run.tsx 12.03% <19.11%> (+1.11%) ⬆️

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 18, 2026

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds 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

Cohort / File(s) Summary
Run command / parallel lifecycle
src/commands/run.tsx
Exported helpers: shouldMarkCompletedLocally, updateCompletedLocallyTaskIds, applyParallelCompletionState, isParallelExecutionComplete, applyConflictResolvedTaskTracking; added executionPromise/shutdownPromise, startParallelExecution, resetParallelStateForRestart, guarded start/restart and graceful shutdown flows.
Parallel executor & merge handling
src/parallel/index.ts
Treat zero-commit successful results as merge failures; consistently increment group/total completed or failed counters across phases; reset tasks to open on interrupted/failed merges/conflicts; added resetTaskToOpen and strengthened merge-failure/requeue bookkeeping.
Worker behaviour & tests
src/parallel/worker.ts, src/parallel/worker.test.ts
Worker tracks commitCount (reset on start, incremented on auto-commit events, included in results); tests add fake-engine scaffolding to validate commit accumulation, cancellation and error flows.
Worktree manager & tests
src/parallel/worktree-manager.ts, src/parallel/worktree-manager.test.ts
acquire enforces max by counting active worktrees via getActiveWorktreeCount; test ensures acquiring after release succeeds without global cleanup.
Parallel executor tests
src/parallel/parallel-executor.test.ts
New helpers and suites exercising merge-failure handling, reset-to-open behaviour, requeue limits, and correct counting of failed vs completed tasks when commit counts or merge outcomes cause failures.
Run command tests
tests/commands/run.test.ts
Tests added for new exported helpers and integration with session/parallel completion logic (completed-locally rules, state application, conflict-resolved tracking, and isParallelExecutionComplete scenarios).
Engine & remote server adjustments and tests
src/engine/index.ts, src/remote/server.ts, tests/engine/execution-engine.test.ts, tests/remote/remote.test.ts
Treat timeouts as failures for iteration classification; clear rate-limited agent tracking on pause; RemoteServer interrupt now awaits stop and resets any active task to open; tests added to cover these behaviours.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main changes: parallel execution state stabilization and worktree lifecycle fixes, directly addressing the core problems in the PR.
Linked Issues check ✅ Passed All coding requirements from #310 are met: worktree capacity leak fixed, false completedLocally prevented, restart state reset, worker commit accounting corrected, merge-failure handling improved, and shutdown synchronization added.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing #310: worktree management, parallel execution state, worker lifecycle, session restart, and shutdown coordination; no unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 93.75% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/issue-310-parallel-stability

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/commands/run.tsx (2)

1995-2003: gracefulShutdown is not re-entrant — add a guard flag.

process.removeListener at Line 2164 runs only after the quit promise resolves. A second SIGTERM arriving while gracefulShutdown is blocked inside await parallelExecutor.stop() will invoke gracefulShutdown concurrently, potentially calling interruptHandler.dispose() and renderer.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: Capture executionPromise before await stop() to make intent explicit.

executionPromise can be set to null by the .finally() handler in startParallelExecution while await parallelExecutor.stop() is suspended, turning the subsequent await executionPromise into await 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=false with existing entry.

The else branch of updateCompletedLocallyTaskIds deletes 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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 of updateCompletedLocallyTaskIds, both executor-status paths of applyParallelCompletionState, and the key combinations of isParallelExecutionComplete are covered.

applyConflictResolvedTaskTracking tests use resolutionCount = 0 and 2, but the guard is < 1, meaning resolutionCount === 1 is 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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the async signal handler (potential unhandledRejection), and the task-reset / savePersistedSession block that follows is never reached, leaving tasks stuck in in_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).rateLimitedAgents directly tests the regression fix

Accessing the private rateLimitedAgents field via as any is acceptable here since the test is specifically verifying the new clearRateLimitedAgents() 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 settling

The current implementation's .then(async () => { await resetTasksToOpen(); ... }) chain settles within the scheduled macrotask window, so the test passes. However, if an additional await is introduced in the chain (e.g. awaiting the send() 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 of as any.

The as any casts on lines 512 and 527 bypass type-checking for the stub objects. A typed partial avoids masking future type breakages in PersistedSessionState.

♻️ 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.

Comment on lines +855 to +858
engine.stop().then(async () => {
if (activeTaskId) {
await engine.resetTasksToOpen([activeTaskId]);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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:

  1. stop() resolves → resetTasksToOpen(['task-123']) sets the task to 'open'
  2. runIteration is still running, waiting on handle.promise
  3. If the agent had already emitted <promise>COMPLETE</promise> before the interrupt arrived, taskCompleted = true and tracker.completeTask runs — 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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/commands/run.tsx (2)

3222-3271: Headless signal handler doesn't guard against stop() rejection.

Unlike the TUI path (which wraps stop() in try/catch), the headless handleParallelSignal calls await 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 current stop() implementation uses Promise.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: _commitCount is accepted but unused — intentional design seam?

The parameter is currently ignored (the function returns taskCompleted verbatim). 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.

@subsy subsy merged commit 0bce640 into main Feb 18, 2026
8 of 9 checks passed
@subsy subsy deleted the fix/issue-310-parallel-stability branch February 18, 2026 20:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

completedLocally blocking my workflow

1 participant