Skip to content

fix(dispatch): runWorkDispatch must throw not exit — preserves sibling spawns in wave dispatch - #1602

Merged
namastex888 merged 1 commit into
devfrom
wish/fix-runworkdispatch-exit-kills-siblings
May 1, 2026
Merged

namastex888 merged 1 commit into
devfrom
wish/fix-runworkdispatch-exit-kills-siblings

Conversation

@namastex888

Copy link
Copy Markdown
Contributor

Summary

ROOT CAUSE of the long-running #1589/#1600 phantom-dispatch saga, found empirically on 4.260430.25 after PR #1601's audit observability shipped.

runWorkDispatch called process.exit(1) on three error paths (wish not found, group not found, startGroup failed). When autoOrchestrateCommand runs Promise.allSettled([runWorkDispatch × N]) for a parallel wave, ANY group's process.exit terminates the entire node process — killing every sibling spawn mid-flight before:

This is exactly why .24 + .25 still phantom-dispatched despite the wish.dispatch.work event firing correctly: the dispatch event landed during the brief window between Group N's PG state mutation and Group N's spawn pipeline being killed by Group N+1's exit().

Repro on .25 (with #1601 audit code installed)

$ cd /home/genie/.genie/worktrees/tui-bottom-bar-opentui
$ env GENIE_TEAM=genie genie work tui-bottom-bar-opentui
🚀 Dispatching Wave 1 (parallel) — data layer for wish "tui-bottom-bar-opentui" — 5 group(s)
✅ Group "1" set to in_progress (assigned to engineer)
🔧 Dispatching work to engineer for "tui-bottom-bar-opentui#1"
   Group: 1
❌ Cannot start group "5": dependency "2" is ready (must be done)

# Then check audit:
$ genie events list --since 3m --type worker.spawn.failed
No audit events found.
$ genie events list --since 3m --type worker.spawn.ok
# Only the dog-fooder's `engineer` shows up — NOT engineer-1
$ genie events list --since 3m --type wish.dispatch.work
1m ago | wish | tui-bottom-bar-opentui | wish.dispatch.work | genie | {"cwd":"/home/genie/.genie/worktrees/tui...

The wish.dispatch.work event fires (Group 1 reached dispatch.ts) but neither worker.spawn (attempt) nor any .ok/.failed terminal event fires — proof that handleWorkerSpawn was killed mid-flight by Group 5's process.exit(1).

Fix

Replace each process.exit(1) in runWorkDispatch with throw new Error(...). Both callers handle the throw correctly:

  • workDispatchCommand (single-group CLI): the outer commander handler in registerDispatchCommands already wraps in try/catch + process.exit, so single-group semantics are preserved.
  • autoOrchestrateCommand (wave): Promise.allSettled collects rejections into the failed array; PR fix(spawn): #1600 — surface silent-fail spawn pipeline (validation + audit events) #1601's wish.dispatch.failed event fires per group; stderr summary prints with the [ErrorClass] prefix.

Test plan

  • bun test src/term-commands/dispatch.test.ts — 86 pass / 0 fail (was 85 + 1 new Group-4 regression-guard asserting zero EXECUTABLE process.exit calls in runWorkDispatch, with comment text excluded).
  • bun run typecheck — clean.
  • Empirical post-merge: genie work tui-bottom-bar-opentui produces worker.spawn.ok for surviving groups OR worker.spawn.failed with structured reason for actual spawn failures. Group 5's "dependency not done" no longer kills siblings.

Files

src/term-commands/dispatch.ts      +21 -10  (3× process.exit → throw)
src/term-commands/dispatch.test.ts +25      (1 regression-guard test)

Saga

This is the third PR in the #1589 → #1600 → (this) chain:

🤖 Generated with Claude Code

…ing spawns

ROOT CAUSE of the long-running #1589/#1600 phantom dispatch saga, found
empirically on .25 after PR #1601's audit observability shipped:

`runWorkDispatch` called `process.exit(1)` on three error paths (wish not
found, group not found, startGroup failed). When `autoOrchestrateCommand`
runs `Promise.allSettled([runWorkDispatch × N])` for a parallel wave, ANY
group's process.exit terminates the entire node process — killing every
sibling spawn mid-flight before:
  - handleWorkerSpawn's worker.spawn audit event fires
  - launchTmuxSpawn reaches the new validateSpawnedPane check
  - any of the new worker.spawn.failed / worker.spawn.ok events fire

This explains why .24 + .25 still phantom-dispatched despite the
`wish.dispatch.work` event firing correctly: the dispatch event landed
during the brief window between Group N's PG state mutation and Group N's
spawn pipeline being killed by Group N+1's exit().

The fix: throw `new Error(...)` on each runWorkDispatch error path. Both
callers handle the throw correctly:
  - `workDispatchCommand` (single-group CLI): the outer commander handler
    in registerDispatchCommands already wraps in try/catch + process.exit,
    so single-group semantics are preserved.
  - `autoOrchestrateCommand` (wave): Promise.allSettled collects rejections
    into the `failed` array, my Round 2 (#1601) wish.dispatch.failed event
    fires per group, and stderr summary prints with the [ErrorClass] prefix.

Empirical confirmation:
  Before: `genie work tui-bottom-bar-opentui` exits silently after Group 5's
  dependency error kills the process. No worker.spawn / .ok / .failed
  events fire for in-flight Group 1.

  After (this fix): Group 5's startGroup throws; Promise.allSettled
  collects the rejection. Group 1's spawn pipeline runs to completion,
  emitting either worker.spawn.ok (success) or worker.spawn.failed
  (real spawn failure surfaced).

Tests:
- 1 new regression-guard in dispatch.test.ts (#1600 Group 4): asserts
  zero EXECUTABLE process.exit calls in runWorkDispatch (comment text
  mentioning the OLD behavior is excluded), and >=3 throw-new-Error
  statements covering wish-not-found, group-not-found, startGroup-failed.
- Total: 86 pass / 0 fail in dispatch.test.ts.
- bun run typecheck: clean.

Followup to #1599 + #1601. Should close the root-cause iteration on the
long-running #1589/#1600 phantom-dispatch saga.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d108efb4-3b7f-4d47-af7e-f9c271f5fdb4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch wish/fix-runworkdispatch-exit-kills-siblings

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.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request refactors the runWorkDispatch function to throw errors instead of calling process.exit(1) to prevent sibling process termination during parallel execution, and adds a regression test to enforce this. Feedback suggests that the fix is incomplete as downstream functions still contain exit calls, and recommends removing a redundant try-catch block that discards original error information.

Comment on lines +659 to +670
// CRITICAL (#1600 Round 3): never call `process.exit(1)` from this function.
// `runWorkDispatch` is invoked from TWO callers:
// 1. `workDispatchCommand` (single-group CLI) — wraps in try/catch and the
// outer CLI handler calls process.exit(1) with the error message.
// 2. `autoOrchestrateCommand` (wave dispatch via Promise.allSettled) —
// collects rejections and reports per-group failures.
// If we exit() here from within a parallel wave, we kill the whole node
// process mid-dispatch — including SIBLING spawns that are mid-flight in
// handleWorkerSpawn. That's THE silent-fail surface from #1589/#1600:
// any group's startGroup failure (dependency not done, already in_progress)
// killed every other group's spawn before audit events could fire. Throw
// instead so the wave-dispatcher's allSettled boundary handles it.

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.

high

While this PR correctly replaces direct process.exit(1) calls in runWorkDispatch with throws, the goal of preventing sibling spawn termination is not fully achieved. The function still calls handleWorkerSpawn (line 743), which contains several process.exit(1) paths in its downstream call chain (specifically in resolveTeamAndResumeOrExit and launchTmuxSpawn within agents.ts). If any group dispatch hits one of these paths, the entire Node process will still be terminated, killing all sibling spawns mid-flight. To fully resolve the issue, those downstream functions should also be refactored to throw errors instead of exiting.

Comment on lines 691 to 697
try {
await wishState.startGroup(slug, group, agentName);
console.log(`✅ Group "${group}" set to in_progress (assigned to ${agentName})`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`❌ ${message}`);
process.exit(1);
throw new Error(message);
}

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.

medium

This try-catch block is redundant as it simply catches an error to re-throw it as a new generic Error. This discards the original error's stack trace and type. Since wishState.startGroup is known to throw Error objects and the callers of runWorkDispatch already handle errors (either by printing them or by inspecting the error class), it is better to let the original error bubble up naturally. Note that if you apply this change, you will also need to update the regression guard test in dispatch.test.ts which currently asserts a minimum count of explicit throw new Error( calls.

  await wishState.startGroup(slug, group, agentName);
  console.log("✅ Group " + group + " set to in_progress (assigned to " + agentName + ")");

@namastex888
namastex888 merged commit e7f580b into dev May 1, 2026
16 checks passed
@automagik-genie
automagik-genie deleted the wish/fix-runworkdispatch-exit-kills-siblings branch September 25, 2026 04:51
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.

1 participant