# Plan — Issue #134: Harden fatal sibling cancellation, explicit-model errors, and checkpoint resume identity

Parent: #128. Branch: `issue-delivery/issue-134`. P1-enabler (runtime correctness).

## Problem (three related runtime correctness gaps)

1. **Fatal-vs-recoverable propagation** — a run-fatal branch did not abort/drain
   in-flight siblings before cleanup/retry. `parallel()`/`pipeline()` used
   `Promise.all`, which rejects on the first rejection and orphans still-pending
   siblings (agents keep running after the run settles on a fatal).
2. **Abort + await in-flight** — `withWorkflowTimeout` raced the execution promise
   against an abort-rejection that settled immediately on abort, so cleanup could
   start while in-flight siblings were still running.
3. **Fail-loud explicit-model** — an explicit unresolved model silently degraded to
   the session default (`agent.ts` warns + `onModelFallback` only logged) rather
   than failing with a stable actionable error.
4. **Checkpoint resume identity** — `hashCheckpoint` hashed only
   `{promptText, kind, choices}`, omitting `default`/`headless`/`timeoutMs`.
   Changing any of those did NOT invalidate a stale replay.

## Scope & file fence

Owns (edited): `src/workflow.ts`, `src/config.ts` (drain-grace constant only),
`src/run-persistence.ts` (no change required — the hash lives in `workflow.ts`),
and new tests.

Does NOT edit (concurrent panes / out of scope): `src/agent.ts` (#147),
`src/herdr-reporter.ts`, `src/task-panel.ts`, `src/workflow-monitor-pane.ts`,
`docs/workflows/monitor-pane.md` (Option D), `src/model-tier-config.ts`,
`src/workflows-models-command.ts`, `src/workflow-tool.ts` tier/role parts
(#142/#159), provenance/changelog/engine-review/model-routing docs.

## Implementation

### §1/§2 Fatal propagation + drain (`src/workflow.ts`)

- Added `SharedRuntime.fatalError` — the first run-fatal captured across
  `parallel`/`pipeline`/`dag`/`retry`/`gate`/`loopUntilDry` and `workflow()` nesting.
- Added `abortRunFatal(error)` helper: records the first fatal, aborts the run
  signal (`workflowController.abort(error)`) so in-flight siblings' runners observe
  the abort and bail, and not-yet-started siblings hit `throwIfAborted()` and reject
  immediately. Returns the captured fatal.
- `parallel()`/`pipeline()` switched from `Promise.all` to `Promise.allSettled` so
  the fan-out DRAINS every thunk before settling (no orphaned siblings). A
  non-recoverable error calls `abortRunFatal` then re-throws; after allSettled the
  captured fatal is surfaced. Recoverable errors stay contained (null slot, or a
  failed dag node that cascade-skips dependents) — independent DAG failure
  semantics preserved.
- `dag()` non-recoverable node errors call `abortRunFatal` so nested
  `parallel`/`pipeline` and later waves drain; recoverable node errors still
  cascade-skip dependents only.
- `retry()`/`gate()`/`loopUntilDry()` abort the run on a non-recoverable fatal so
  any in-flight sub-agent drains before the run settles.
- `createLimiter(limit, signal)` — once the run is aborted, a queued waiter
  rejects immediately instead of waking to do work under an aborted signal, so the
  in-flight count drains.
- `withWorkflowTimeout` — on abort, await the execution promise (which drains via
  allSettled) bounded by `DEFAULT_DRAIN_GRACE_MS` (5s, new in `src/config.ts`)
  before rejecting with the abort reason. Siblings that honor the abort bail within
  a few ticks; a runner that ignores the signal is force-settled after the grace so
  cleanup never hangs on a stuck agent.

### §3 Fail-loud explicit-model (`src/workflow.ts`)

- The workflow layer's `onModelFallback` callback now throws a stable actionable
  `WorkflowError` (`HARNESS_NOT_WIRED`, non-recoverable) when the unresolved spec
  is the explicit agent/agentType model (`explicitModel === spec`). Throwing
  inside the callback propagates out of `WorkflowAgent.run`'s model-resolution
  block before any session default is used.
- The documented fallback is RETAINED only for omitted model/tier cases
  (role/tier/phase routing misses, untagged agents) — those keep the legacy
  soft-degrade. Phase-model misses stay a soft warning (a routing hint, not an
  explicit pin).
- `src/agent.ts` is NOT edited (#147 owns it); the fail-loud is enforced in the
  workflow layer (which I own) via the `onModelFallback` seam the agent already
  exposes.

### §4 Checkpoint resume identity (`src/workflow.ts`)

- `hashCheckpoint` now folds `default`, `headless`, `timeoutMs` into the identity
  ONLY when explicitly set. A legacy journal entry (which never carried these)
  produces the identical pre-#134 hash and replays unchanged (backward
  compatible); adding or changing any behavior option invalidates the stale
  replay so a detached run cannot silently restore a reply produced under
  different headless/timeout semantics.

## Acceptance criteria → tests (`tests/issue-134-runtime-correctness.test.ts`)

- Fatal cannot leave siblings running after settle:
  `parallel(): a run-fatal aborts in-flight siblings` (slow sibling observes abort,
  bails; original fatal propagates, not a sibling abort).
- Recoverable failures stay contained (independent semantics):
  `parallel(): a recoverable sibling failure does NOT abort siblings`.
- Cleanup starts only after in-flight drains:
  `pipeline(): a run-fatal drains in-flight items`; `dag(): a NON-recoverable fatal
  node aborts the run so sibling waves drain`.
- Explicit missing model returns a stable actionable error:
  `runWorkflow: an explicit unresolved model fails loud`; resolving/omitted
  models are unaffected (no false fail-loud; documented fallback retained).
- Changing any checkpoint behavior option invalidates stale replay; unchanged
  old entries stay compatible:
  `checkpoint(): a legacy entry still replays`, `changing default/headless/timeoutMs
  invalidates`, `two checkpoints differing only by default get distinct hashes`,
  `an unchanged headless run replays its own journaled reply`.

## Verification floor

- `npm run build` ✅
- `npm test` (biome check + build + 1592 unit tests) ✅ 0 fail
- `npm run check:workflow-lock` ✅ (0 errors; 7 pre-existing external-source warnings)
- `npm run test:package-smoke` ✅ (0.84.1)
- `npm run test:compat` ✅ (1592 compat tests)

Every WorkflowManager/background-adjacent test declares an explicit per-test
timeout (lesson #133: `--test-timeout=0` turned a deadlock into a 47-min hang).
Fatal-drain tests use bounded defers so a regression cannot hang.

## Delivery

Push `issue-delivery/issue-134`, open a PR. Do NOT merge, publish, or repin.
Stop at the PR.