@sema-agent/core 7.13.0 → 7.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/agents/subagent.d.ts +4 -9
  3. package/dist/agents/subagent.js +16 -12
  4. package/dist/agents/teacher.js +1 -1
  5. package/dist/brain/reasoning.d.ts +23 -0
  6. package/dist/brain/reasoning.js +35 -4
  7. package/dist/brain/stream-engine.js +35 -17
  8. package/dist/brain/timeout.d.ts +25 -4
  9. package/dist/brain/timeout.js +1 -1
  10. package/dist/core/ask-origin.d.ts +28 -9
  11. package/dist/core/ask-origin.js +10 -5
  12. package/dist/core/auto-mode.d.ts +3 -3
  13. package/dist/core/checkpoint-seat.d.ts +18 -0
  14. package/dist/core/checkpoint-seat.js +6 -0
  15. package/dist/core/checkpoint-store.d.ts +9 -16
  16. package/dist/core/checkpoint-store.js +7 -4
  17. package/dist/core/engine-notice.d.ts +3 -1
  18. package/dist/core/env-knob-announce.d.ts +16 -0
  19. package/dist/core/env-knob-announce.js +14 -0
  20. package/dist/core/gate-fold.js +2 -0
  21. package/dist/core/gate-lanes.js +21 -12
  22. package/dist/core/gate-outcome.d.ts +42 -3
  23. package/dist/core/gate-outcome.js +22 -0
  24. package/dist/core/governance-codes.js +1 -1
  25. package/dist/core/hooks.d.ts +5 -16
  26. package/dist/core/hooks.js +6 -4
  27. package/dist/core/mcp.d.ts +3 -2
  28. package/dist/core/mcp.js +5 -11
  29. package/dist/core/memory-engine/engine.d.ts +2 -2
  30. package/dist/core/runner/advertised-writable-dirs.d.ts +74 -0
  31. package/dist/core/runner/advertised-writable-dirs.js +48 -0
  32. package/dist/core/runner/assemble-result.d.ts +5 -0
  33. package/dist/core/runner/denial-limit-arms.d.ts +7 -9
  34. package/dist/core/runner/denial-limit-arms.js +8 -10
  35. package/dist/core/runner/gate-exit.d.ts +15 -3
  36. package/dist/core/runner/gate-exit.js +6 -4
  37. package/dist/core/runner/prepare-caps-and-workflow.d.ts +34 -4
  38. package/dist/core/runner/prepare-caps-and-workflow.js +47 -12
  39. package/dist/core/runner/prepare-gate-stations.d.ts +1 -1
  40. package/dist/core/runner/prepare-hands-readface.d.ts +10 -4
  41. package/dist/core/runner/prepare-hands-readface.js +5 -14
  42. package/dist/core/runner/prepare-inherited-gate.d.ts +5 -3
  43. package/dist/core/runner/prepare-inherited-gate.js +1 -1
  44. package/dist/core/runner/prepare-memory.d.ts +31 -34
  45. package/dist/core/runner/prepare-memory.js +73 -51
  46. package/dist/core/runner/prepare-policy-chain.js +5 -2
  47. package/dist/core/runner/prepare-prompt-inputs.d.ts +4 -0
  48. package/dist/core/runner/prepare-prompt-inputs.js +2 -2
  49. package/dist/core/runner/prepare-question-face.d.ts +10 -8
  50. package/dist/core/runner/prepare-question-face.js +1 -3
  51. package/dist/core/runner/prepare-safety-scan.js +1 -1
  52. package/dist/core/runner/prepare-task.js +57 -48
  53. package/dist/core/runner/prepare-wiring-manifest.d.ts +2 -2
  54. package/dist/core/runner/prepare-wiring-manifest.js +1 -1
  55. package/dist/core/runner/run-compaction-machinery.js +2 -0
  56. package/dist/core/runner/run-harness-handlers.js +3 -1
  57. package/dist/core/runner/stream-settle-backstop.js +1 -1
  58. package/dist/core/runtime-caps.d.ts +21 -0
  59. package/dist/core/runtime-caps.js +5 -1
  60. package/dist/core/task-event.d.ts +11 -2
  61. package/dist/core/task-registry-shared.js +8 -6
  62. package/dist/core/task-result.d.ts +15 -0
  63. package/dist/core/terminal-cause.d.ts +6 -2
  64. package/dist/core/tool-policy.d.ts +34 -28
  65. package/dist/core/tool-policy.js +29 -5
  66. package/dist/core/tool-roster.js +2 -0
  67. package/dist/core/tool-spec.d.ts +13 -12
  68. package/dist/core/types.d.ts +2 -1
  69. package/dist/core/types.js +1 -0
  70. package/dist/core/wiring-manifest.d.ts +16 -9
  71. package/dist/core/wiring-manifest.js +8 -3
  72. package/dist/index.d.ts +5 -4
  73. package/dist/index.js +4 -3
  74. package/dist/orchestration/run-workflow-tool.d.ts +8 -8
  75. package/dist/orchestration/run-workflow-tool.js +1 -1
  76. package/dist/orchestration/workflow-primitives.d.ts +4 -3
  77. package/dist/orchestration/workflow-primitives.js +3 -3
  78. package/dist/orchestration/workflow-types.d.ts +14 -0
  79. package/dist/orchestration/workflow.d.ts +41 -4
  80. package/dist/orchestration/workflow.js +23 -5
  81. package/dist/tools/fs/fs-shared.js +9 -3
  82. package/dist/tools/fs/read-deny.d.ts +24 -8
  83. package/dist/tools/fs/read-deny.js +20 -1
  84. package/package.json +2 -1
  85. package/test/export-surface.snapshot.json +25 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.14.0 — 2026-09-11
4
+
5
+ ### Narrowing — the agent's own key-bearing settings files are refused on the structured read faces by default (#685; P0; ruling B: the session record stays verbatim, the fix is on the READ face; sema form — CC 2.1.250 has no read-face deny table, the table itself is the registered divergence; @server @cli @test)
6
+ - **Before.** The built-in read deny table (`READ_FACE_BUILTIN_DENY_TABLE`, `src/tools/fs/read-deny.ts`) guarded credential directories (`.ssh`, `.aws`, `.netrc`, `.npmrc`, …) and named `.claude*`/`.mcp.json` as deliberately NOT listed ("agent config is routinely read for debugging"). Under default wiring a `Read ~/.sema/settings.json` (the shell's user settings — its `env` block carries the engine URL and provider API keys) returned the file verbatim: the key landed in the tool result, in the model context, and in the session record; a cli replay audit found it there in cleartext.
7
+ - **After (one tier, no new rule).** A fifth built-in tier **`agent-config`** joins the closed set `READ_DENY_BUILTIN_TIERS` (`credentials`, `shell-history`, `browser`, `wallet`, `agent-config`) and is default-ON through the existing derived default expression (every tier except `shell-history`). Its rows name the agent's own configuration FILES whose env / server blocks carry API keys — `.sema/settings.json`, `.sema/settings.local.json`, `.sema.*` (`.sema.json`), `.claude/settings.json`, `.claude/settings.local.json`, `.claude.*` (`.claude.json`), `.mcp.json`, `.ai-agent/.env` and `.sema/engine-data/.env` (the engine host's documented data-root key file under the default and the shell's layouts), `.codex/config.toml`, `.cursor/mcp.json`, `.continue/config.json`, `.continue/config.yaml`, `.aider.conf.yml`, `.gemini/settings.json` (every one a file whose `env` / server block takes literal values). Token STORES of the same tooling join the existing **`credentials`** tier, not the new one — `.credentials.json` (the shell's OAuth store under either brand's config home), `.codex/auth.json`, `.config/github-copilot`, `.gemini/oauth_creds.json` — so a deployment that switches `agent-config` off to debug its agent configuration does not expose the OAuth stores beside it. Rows are FILES, not the config directories: under the shell's layout `~/.sema/engine-data` is the engine data root and hosts the memory library the model reads and edits (Edit requires a prior Read), and `.sema/agents`, `.claude/skills`, `.cursor/rules`, `.codex/sessions` are working material — a directory-wide row would have needed a data-root passthrough (one more rule; the write face carries exactly that special case and this change does not copy it). The disposition is the credentials tier's existing one, unchanged in kind: a structured face whose own target is a listed file (Read; Grep/Glob/RepoMap given that path) refuses typed (`read_path_denied`, the pattern named, zero content bytes, no ask); a Grep/Glob TRAVERSAL over a scope containing one excludes it and says so in a trailing note (`entries matching the sensitive-path read deny list … were excluded`), no error, the file's bytes never in the result; the classify shell gate's reader arm (`grep needle .claude/settings.json`, `cat …`) demotes a listed operand to a MANDATED ask (B-057: the read boundary's question — neither a stored allow rule nor the read-only arm retires it; a person's yes releases the read). Rules: +0 (a tier row is data on the one compiled matcher every judgment point already consumes); the JSDoc "NOT listed" sentence for `.claude*`/`.mcp.json` is withdrawn.
8
+ - **User-visible path.** A default-wired run that reads `.claude/settings.json` / `.sema/settings.json` / `.mcp.json` now gets a typed refusal from Read (and a permission card from a `grep`/`cat` of it under `shellGate:"classify"`) where it used to get the file. **Deployment opt-out (existing knob, no new switch):** `RunnerDeps.readDenyBuiltinTiers` / `HandsToolkitOptions.readDenyBuiltinTiers` = `["credentials", "browser", "wallet"]` restores the previous face for the settings files while keeping the token stores refused; `readDenyBuiltinExclude` removes single rows by their pattern text (e.g. `[".mcp.json"]`). Additions (`readDenyPatterns`) are unchanged and remain add-only.
9
+ - **Fail-open reverse check — which reads of a key-bearing file still do NOT ask or refuse?** (a) a workspace `.env` / `.env.*` — deliberately unchanged (the standing ruling: workspace material; the write guard covers it; only the engine host's own data-root `.env` is listed); (b) a settings file under a relocated config home (`SEMA_CONFIG_DIR` / `CLAUDE_CONFIG_DIR`) or a relocated data root (`AGENT_DATA_DIR`) — the rows name the default spellings, a deployment that relocates adds its own row (the write guard has the same shape); (c) **the shell reader arm under the DEFAULT `shellGate`.** The fact, stated as the deployments will meet it: the read face's built-in `agent-config` tier blocks the STRUCTURED reads (Read; Grep/Glob/RepoMap targets and traversals); the Bash reader arm is covered under `shellGate:"classify"` (the probe mandates the ask for a listed operand, as above) and `"always"` (every shell call asks; a stored `Bash(cat:*)` allow rule does NOT clear it — the operator doctrine is a mandated ask); when `shellGate` is absent or `"off"` a Bash read goes through NO probe — `cat .claude/settings.json` runs with zero asks and the key reaches the session record — exactly as the `credentials` tier has always behaved on that arm (the module header's declared scope: a structured-face floor, the full shell is the approval chain's jurisdiction). Measured and pinned (`test/backlog685-agent-config-read-deny.test.ts`, the "declared scope" block). **This is the default deployment's shape today**: the server's `MANUAL_MODE_SHELL_GATE` is opt-in and unset by default, the cli mints no `shellGate` of its own, so `TaskSpec.shellGate` is absent ⇒ `"off"`. Not widened by this change (the arm is the existing design, same for every tier); follow-up **#696** (P1 design: whether the read-face floor should be decoupled from the shellGate tier and judge the shell reader arm unconditionally). @server @cli: this is the sentence to read for the P0's remaining arm; (d) `.sema/agents/*`, `.claude/skills/**`, `.cursor/rules/**`, `.codex/sessions/**` and `.claude/projects/**` transcripts — working material, or transcripts (a different class — the shell-history posture — not this change); (e) a `settings.json` outside an agent-config directory (`config/settings.json`, `.vscode/settings.json`) — reads normally (pinned as the control).
10
+ - **Export surface.** No public name added or removed. Closed-set member +1: `READ_DENY_BUILTIN_TIERS` (`ReadDenyBuiltinTier` widens by `"agent-config"`); `READ_FACE_BUILTIN_DENY_TABLE` +19 rows (4 `credentials`, 15 `agent-config`); `READ_FACE_DEFAULT_DENY_ENTRIES` grows by the same 19. A consumer that pinned the tier vocabulary or the default entry list as a closed set re-pins. `readDenyBuiltinTiers` configs listing the four old names keep working (exactly-these semantics: they now deliberately leave `agent-config` off).
11
+ - **Downstream.** `@server`: mirror the tier in any admin face that renders `READ_DENY_BUILTIN_TIERS` / the default table; a deployment that relocates `AGENT_DATA_DIR` should add its own `<root>/.env` row via `readDenyPatterns`; a persisted checkpoint's read-face section carries additions only (built-ins resolve at resume from the deployment), so no stored row changes shape. `@cli`: the Read refusal text names the pattern (`sensitive-path read deny list (pattern ".sema/settings.json")`) — the existing `read_path_denied` card applies; the classify card for `grep …/settings.json` is the existing B-057 mandated-ask card (no "don't ask again" offer, by design); `.mcp.json` as a basename also covers plugin-marketplace manifests (`…/plugins/marketplaces/*/external_plugins/*/.mcp.json` — they carry `env` blocks too), so a structured Read of one now refuses and a Grep over a plugin tree excludes them with the note. `@test`: G1–G8 in the release criteria (draft in the car receipt).
12
+ - Pins: `test/backlog685-agent-config-read-deny.test.ts` (table: tier membership, both brand spellings / case fold / both separator families, the readable working-material list incl. the memory library under `.sema/engine-data` and workspace `.env`, tier-off leaves the credentials rows; Read face under default wiring: three files refused with zero asks and zero key bytes in the session record, a covering stored `Read(//root/**)` rule does not reach the in-tool refusal, controls read normally and the key does flow from an unlisted file, tier-off reads the settings file while `.credentials.json` stays refused, Grep traversal excludes the rows; classify shell reader arm: no ⇒ one mandated ask and a clean record, yes ⇒ the read happens, a stored exact allow rule does not clear it, unlisted-file and tier-off controls at zero asks). Also pinned: the declared scope — `shellGate` absent ⇒ `cat .claude/settings.json` runs with zero asks and the key reaches the record; `"always"` ⇒ one ask, a stored `Bash(cat:*)` rule does not clear it. Mutation measured: dropping the 15 agent-config rows reds 9 of 19 (every refusal / mandate pin), the 10 controls / scope pins stay green. `test/backlog245-deny-builtin-config.test.ts` tier-vocabulary pin re-spelled for the fifth tier.
13
+ ### Behaviour — one rule for every prompt-advertised writable directory: the fence admits what the prompt teaches (#691; P1 组合病; cli [6900]; @server @cli @test)
14
+ - **Before.** The `# Memory` instruction taught "write to it directly with the Write tool" at `{{MEMORY_DIR}}` while the fs root fence knew nothing of that directory: on a default wiring (no `additionalDirectories`, memory root under `~/.sema/engine-data/memory`) every memory Write was refused `path_not_in_root` ("resolves outside the allowed root"). The scratchpad had the opposite half of the same rule hand-rolled in the hands phase (admit + materialize), and the instruction's supply condition checked only that a `Write` tool was mounted and reachable — two judgments of one fact.
15
+ - **After (the rule, one sentence).** A directory the prompt advertises as writable is a directory the root fence ADMITS, and the SAME admission decides the hole and the teaching — `AdvertisedWritableDirs` (`src/core/runner/advertised-writable-dirs.ts`), one seat, two advertisers: the memory phase admits its write plane's root (`MemorySessionHandle.writableRoot`, the very path the instruction names) iff the write channel's first conjunct holds (`Write` mounted, not excluded, and on the filesystem the store lives on — a remote env needs `memoryPersistenceCapable: true`) and a write face exists (`writeScope !== null`); the `# Memory` write instruction and the preference discipline are taught iff that admission held. The hands phase admits the scratchpad through the same seat (its exclusive-create marker materialization unchanged) and folds the seat's canonical roots into the write-capable containment allowlist before the toolkit compiles. Admission is fail-closed canonicalization through the fence's own ExecutionEnv; a refused admission is announced ONCE — `<advertiser> advertised writable directory skipped (cannot canonicalize): <path> — <code>: <message>` on `onError` (phase `config`) — and the advertiser then does not advertise (memory: no instruction; the scratchpad's silent narrowing is now loud on the same outlet, its env-block spelling unchanged).
16
+ - **Phase order (driver-internal).** The memory phase now runs BEFORE the hands phase in `prepareTask` (the engine owns the write root's coordinate; the toolkit compiles its allowlist once), so the memory write gate is installed before the band compiles. The two roster facts the memory phase needs from the not-yet-mounted band (`writeToolsMounted`, `rosterCanPersist`) read the band's catalog PLAN — the hands rows whose declared effect is not `read` (the file-write trio and the full shell), planned iff hands are enabled and not read-only — beside the assembled caller/MCP tools; the #181-F5 pins (handsReadOnly / excludeTools Write / hands-less caller Write) guard the plan against the compile. `PrepareMemoryInput` and `PrepareHandsReadFaceInput` gain `advertisedWritableDirs?` (phase-api floor 947 → 949).
17
+ - **Observable (pinned both ways, `test/backlog691-advertised-writable-dir.test.ts`, 9 pins; 6 targeted mutations each red ≥1).** Default wiring: the model's Write into the advertised memory dir lands, zero `path_not_in_root`, the `# Environment` additional-directories line now lists the memory root beside any scratchpad (the same disclosure the scratchpad already had); the engine's content gate still refuses a framing break-out inside the admitted root (the hole is not a content door). `writeScope: null` and `handsReadOnly`: no hole, no teaching, the read-only notice as before. Remote env undeclared: no admission attempt, no teaching, the notice once. Remote env declared but the sandbox cannot resolve the host path: skipped loudly, not taught. Remote env declared + resolvable: taught. Scratchpad ghost: skipped loudly; scratchpad + memory root on one allowlist.
18
+ - **Fail-open reverse check — which directory does the fence open that the prompt does not advertise?** None: `roots` is filled only through `admit`, and every `admit` caller decides its advertisement off the returned verdict; the memory phase asks only when it would teach (write face + first conjunct), so a read-only layering, a read-only band or an excluded `Write` opens no hole. Rules 2 → 1 (the scratchpad's inline admit and the instruction's roster-only supply condition fold into the seat).
19
+ - **Review round 1 (adversarial; four REAL findings adopted, one noted).** ① The write plane's root is REFUSED (loud `onError`, channel closed, not taught) when it physically contains another scope's directory: the root scope owns `memoryDir` and later scopes nest beneath it (layout.ts `registerScope`), and the file tools' fence is directory-granular — admitting the root would have exposed sibling scopes (another user's, an unadmitted org layer) to Read/Write. The check is `MemoryEngine.readonlyDirNamesUnderRoot` (made public), the same set the harvest already excludes. Consequence: a deployment that stacks several scopes in ONE memory dir (the pre-v2 form) gets no file-tool memory writes and hears why — the layout's non-disjoint scope roots are the design defect, ledgered. ② `AdvertisedWritableDir.expectCanonical`: the fence's canonical spelling must equal the coordinate the engine's write gate and harvest key on (`writableRoot`); a sandbox that exposes the host root under another name is a skip naming both spellings, never a hole the content gate cannot see. ③ The rule's other half for the scratchpad: a skipped scratchpad is NOT advertised — the `# Scratchpad Directory` section renders only when the seat did not skip it (`verdictOf("scratchpad")`, prepare-prompt-inputs). ④ The memory phase is split into MOUNT (before hands) and `composeInjection({ rosterCanPersist })` (after hands, at the phase's former position): the one destructive read — the announcement drain inside `inject()` — no longer precedes a hands-phase refusal, and the roster-wide persistence verdict reads the MOUNTED roster exactly as before (a StubExecutionEnv mounts no shell; a catalog plan counted one — pinned). `PrepareMemoryInput` loses `rosterCanPersist`; `PrepareMemoryResult` loses `memoryBlock`/`recallDisciplineSegment`/`seedFiles` and gains `composeInjection` (driver-internal phase contract; direct callers are the test rigs). Noted: the seat's judgment never throws on the env's Result answers; its skip outlet is the caller's `onError` sink and is deliberately unguarded, like every other use of that sink.
20
+ - **Review round 2 (adversarial; one adopted, two ledgered).** Adopted: a write root that contains the engine's CONTROL PLANE (a `FileMemoryEngineBackend` pinning `controlDir` inside the memory dir — the dual-root branch refused that overlap at mount, the single-plane branches never did) is refused by the same one-list refusal as sibling scopes ("what the root contains that is not the model's to touch"). Ledgered, not fixed: (a) the sibling-scope check is a prepare-time registry snapshot — a scope registered by ANOTHER session mid-run nests a new directory under an already-admitted root (the same non-disjoint layout defect; the design ticket below is the fix, not a per-write re-read); (b) on a case-insensitive volume a memory dir configured with case-variant spelling splits the engine's coordinate (`layout.canonicalize` = JS `realpathSync`, casing-preserving) from the fence's (`NodeExecutionEnv.canonicalPath` = libuv `realpath`, on-disk casing) — today that is a loud `expectCanonical` skip; before #691 the same configuration silently ran the memory write gate against a key it never matched (measured on this volume: `realpathSync` keeps `LOWER/DIR`, `fs.promises.realpath` returns `lower/dir`). One canonicalizer for both is the fix (engine-wide, its own ticket).
21
+ - **Review rounds 3–4.** Round 3 caught that the round-2 guard had been edited but not committed (the mutation harness restored the file while the edit was uncommitted); re-landed in its own commit. Round 4 (verification): approve, no material findings — the guard reaches all three mount branches with the paired engine/handle, both refusal pins match, and every earlier axis reads clean.
22
+ - Pins after round 2: `test/backlog691-advertised-writable-dir.test.ts` 15 (every adoption pinned; 11 targeted mutations, each red ≥1 — including push-before-compare on the seat and the dropped control-plane check); `memory-engine-runner.test.ts` declared-capable remote arm now models the shared mount (the sandbox resolves the host root).
23
+ - Not fixed, recorded: the memory layout's non-disjoint scope roots (root scope = `memoryDir` itself) — the reason ① refuses multi-scope single-dir deployments; the fix is a layout that gives every scope its own subdirectory (migration + registry adoption), a design ticket. The memory write-gate ref cell (`memoryWriteGateRef`) could now be a direct value (the memory phase runs first) — a pure cleanup with six test call sites.
24
+ ### Contract batch — every fact a downstream branches on is a closed-set structural position; prose only renders (#688 C1–C5, #692 C-a–C-d; per-key dispositions in `docs/wire-consumption/7.14.0.md`; @server @cli @client-core @test)
25
+ - **C1 · the classifier deny's FORM.** Before: the three ways an auto-mode classifier round ends (could not run / answered outside its contract / ruled a block) reached the wire as one layer word `deniedBy: "classifier"` plus prose, and the "could not run" half rode a separate `classifierUnavailable: { cause }` seat on the deny observer's payload (never on `tool_end.gate`). After: `GateDisposition.denied.cause?: "unavailable" | "parse_error"` (`CLASSIFIER_DENY_CAUSES`, `isClassifierDenyCause`; layer table `DENIED_BY_MAY_CARRY_CAUSE`, invariant I5 in `screenGateOutcome`) — attested by the two classifier stations on the deny they mint (call-bound, identity-keyed, transferred across the fold's rewrite clone), read once into `GatePass.denyCause`, minted onto the disposition at the exit; a ruling block carries none. An ancestor's frozen classifier reached through the inherited chain keeps its `policy` attribution (station row 14) and carries the cause beside it. The WHY (`error` / `timeout`) stays on the deny sentence and the `auto_mode.classified` trace frame. The `classifierUnavailable` seats are retired (below) — the ask-side ones had not been engine-written since #661, the deny-side one spelled three forms as one word.
26
+ - BREAKING · `PermissionDeniedPayload -classifierUnavailable` (read `payload.gate.disposition.cause === "unavailable"`).
27
+ - BREAKING · `PermissionResult -classifierUnavailable` (both arms; the fact is engine-attested, never a caller member).
28
+ - BREAKING · `PreToolUseResult -classifierUnavailable` (the alias face of the previous line).
29
+ - BREAKING · `AskRequest -classifierUnavailable` (the tool_approval card / inbox row key; dead since #661).
30
+ - BREAKING · `AskCarry -classifierUnavailable` (the park closure's structural seat).
31
+ - BREAKING · `PendingAction -tool_approval.classifierUnavailable` (the durable row; a stored key on an old row is ignored by the projection).
32
+ - BREAKING · `CheckpointSummary -classifierUnavailable` (the parked-ask summary projection).
33
+ - **C2 · `ASK_ORIGINS` member `unresolvable` → `ancestor_marked`.** Before: the word said "unresolvable" for an ask whose whole point is that an ancestor MARKED it for the park (its resolution is the park's business). After: the word names WHO raised it, like every other member; the ladder arm and both lane tables re-keyed; no alias, no mapping.
34
+ - BREAKING · `ASK_ORIGINS -unresolvable` (+`ancestor_marked`; a durable row parked under the old word reads as a non-member at the resume screens — `isAskOrigin` — while its `org` fact still governs it).
35
+ - **C3 · the mechanism behind `rule_store_unavailable`.** Before: one origin word for two mechanisms (the store could not be read / the CALL could not be read against the person's rows), told apart only by the ask's message text. After: `RULE_STORE_UNREADABLE_KINDS = ["store", "call"]` (`RuleStoreUnreadable`, `isRuleStoreUnreadable`); the gate reads the lane's answer ONCE into `AskOriginFacts.ruleStore` (the `"unavailable"` input word is gone — a type-level input, not a wire key), stamps `ruleStoreUnreadable` beside `origin` on the surviving ask (a policy's self-declared value is removed there) and carries it onto `AskRequest`, `AskCarry`, `PendingAction.tool_approval`, `CheckpointSummary`. Present ⇔ `origin === "rule_store_unavailable"`. Additive.
36
+ - **C4 · `WorkflowRun.errorCode?: string`.** Before: a run that failed — on a parked child in particular — carried `status: "failed"` and free text; only `WorkflowAgentRun.errorCode` was machine-readable. After: the run-level code in the same vocabulary, attributed ① from the thrown value's own `code` (a park ⇒ `workflow.agent_parked`, a refusal ⇒ its class code, a re-thrown typed error ⇒ its code), ② else the FIRST failed child's code (earliest `endedAt`, spawn order on a tie), ③ else absent (never `""`; absent on every completed run). Additive.
37
+ - **C5 · `blocked` is TERMINAL.** `TerminalCause.blocked` / `TaskStatus` JSDoc and `docs/INTEGRATION-CORE.md` §3.1's five-state table now say it in one sentence: the agent's own `report_blocked` self-report ends the leg and waits for nobody; the waiting-on-a-person words are `suspended` (pre-action approval) / `needs_review` (review pause), `parked` on the registry face. Docs only; pinned against the closed set.
38
+ - **C-a · `memory.consolidation_withheld` audience `user` → `operator` (value-domain).** Before: a `user` row minted where no user can be reached — a consolidation run is a host-driven maintenance run outside any session; `detail` carries scope / runId / counts and no `sessionId`, and no scope→session route exists (a memory scope is shared by many sessions). After: the operator's row, like its five siblings; the reader-facing half rides the receipt (`withheldFromCleanArm` / `withheldNeverSent`). Chosen over "add a routable position" because the position does not exist to add.
39
+ - **C-b · `TaskResult.stats.usageMissing?: true`.** Before: a leg whose provider reported no usage frame, and every host-minted zero result (a refused spawn's journal entry, a prepare-time refusal, a synthetic abort), wrote `tokens: 0` indistinguishably from "free". After: the key is present when at least one model round carried the brain's `usageMissing` mark (the `turn_end` frame's word, now on the leg's stats once) or the result never ran a measurable round; the numbers stay required and stay what was counted — a LOWER BOUND beside the key. Absent ⇔ every round reported usage; never `false`. Additive.
40
+ - **C-c · `reasoningEffortLevelsOf(model)` (export).** Before: the per-API effort acceptance set the clamp applies lived only inside `resolveEffort`'s callers; a host offering "supported tiers" read a global default it could not vouch for. After: the one table, two reads — anthropic effort lane = the declared `effortLevels`, budget lane = `undefined` (a token gradient, no tier set), Responses / completions = the sanitized `reasoningEffortLevels` or `DEFAULT_EFFORT_LEVELS`, binary formats and `supportsReasoningEffort:false` (openrouter excepted) = `undefined`. `sanitizeEffortLevels` is shared with `resolveEffort`, so `resolveReasoning(...).effective ∈ reasoningEffortLevelsOf(model)` wherever the set is defined. Per API, not per model id — `Model` grows no key.
41
+ - **C-d · which plane denied auto mode.** Before: `RuntimeCaps.autoMode: false` arrived already folded from the resolver (org / local / settings indistinguishable) and the manifest said `denied`. After: `RuntimeCaps.autoModeDenySource?: "org" | "local" | "settings"` (`AUTO_MODE_DENY_SOURCES`, `isAutoModeDenySource`) — the resolver's own word beside the bit, screened with it (a source beside a non-false bit, or outside the set ⇒ `onError` + deny coined + `resolver_fault`, never defaulted); `autoModeArmReasonOf` mints the whole `AutoModeArmFact { armed, reason, deniedSource? }` (exported) and `WiringManifest.autoMode.deniedSource` carries it only beside a STATED `denied` (`readAutoModeFact` refuses it elsewhere). Additive.
42
+ - **Fail-open reverse check — which fact still lives only in prose after this batch?** The WHY behind a classifier unavailability (`error` vs `timeout`) is deliberately prose + trace (not a branching fact); the lexer's reason behind `ruleStoreUnreadable: "call"` is prose (the mechanism is the fact; the sentence is the rendering); a workflow body's own codeless throw stays `error` text with no code (there is no fact to structure). Everything else the two integration audits named now has a position.
43
+ - Pins: `test/backlog616-classifier-unavailable.test.ts` (C1, rewritten), `test/backlog688-contract-batch.test.ts` (C3 / C4 / C5 / C-a / C-b / C-c / C-d, each double-sided), re-pinned: backlog661, design503, gate-outcome-stations S7, backlog564, gate-outcome-adversarial-r3, backlog625, backlog642 (park ⇒ `workflow.agent_parked`), design383 §1.4, governance-codes, prepare-wiring-manifest, backlog529 (unchanged, green). Export surface +12 (snapshot re-baselined). Ratchet raises: gate-lanes 1425→1445, assemble-result 620→630, run-harness-handlers 583→590, prepare-caps-and-workflow 787→800.
44
+
45
+
46
+ ### Behaviour — a child inherits the parent's RESOLVED checkpoint seat, both delegation lanes, one rule (#683; P1 design; test [6896] G-1/2/3/10; server B-060; @server @cli @test)
47
+ - **Before.** Both delegation lanes handed a child only the parent's OFF switch: the Task lane copied `checkpointStore: "disabled"` off `ctx.checkpointStoreDisabledForChildren`, the `run_workflow` lane off `RunWorkflowToolDeps.parentCheckpointStoreDisabled` / `buildWorkflowPrimitives(…, parentCheckpointStoreDisabled)`. A child whose seat was left unset re-resolved `RunnerDeps.checkpointStore` on its own — so a parent parking through a SPEC-level store (a deployment that wires the store per task, no store on the Runner deps) spawned children that found no store: the background child's and the workflow child's asks fell to the fail-closed deny (`delegation.ask_unresolvable`, `parkLaneExisted: false`) while the parent could park.
48
+ - **After (the rule, in one sentence).** The driver mints the run's checkpoint SEAT once, right after the config doors (`checkpointSeatOf(spec, resolveCheckpointStore(spec, deps))`, `src/core/checkpoint-seat.ts`): the store object the run parks in, the word `"disabled"` when the caller disarmed the run, or NO seat when the run has no store anywhere. A seat rides the trusted ctx seat `ToolExecuteContext.checkpointStoreForChildren` and the Workflow mount dep `RunWorkflowToolDeps.parentCheckpointStore` and lands on every child spec VERBATIM (Task sync / background / fork: `subagent.ts` one spread; workflow: `buildWorkflowPrimitives(…, parentCheckpointStore)`); no seat ⇒ no key, and the child's own door resolves its runner's deployment exactly as before (a split child-execution runner may carry a store the host has none of — `test/agent-park-jian4-durableapproval-forwarding.test.ts:643`, the design/153 件4 E2E chain, pins that topology and is unchanged). Rules 2 → 1: "deps baseline + only the OFF switch travels" is replaced by "a parent with a seat hands the whole seat down". Tighten-only in both directions: `"disabled"` lands UNCONDITIONALLY (a deployment baseline store does not re-arm a run the caller disarmed); a store lands only when the child's seat is unset — a baseline-pinned or trusted-script store is an explicit author and keeps winning (the baseline-principal precedent); a governed script's own `checkpointStore` is stripped and announced as before (`workflow.governance_key_stripped`, not on the whitelist). The child's own door re-reads an inherited store through the same `execution.outcome` predicate (idempotent on the object the parent already passed; no "inherited ⇒ skip" special case).
49
+ - **Review round (adversarial, two REAL findings, both fixed and pinned).** ① `workflowAgentCallKey` hashed the WHOLE child spec — a store object carries its own live state (an in-memory store's row map), so every park moved the journal key of the very call that parked, and an unchanged script's resume read its parked ordinal as a changed call. The checkpoint seat is deployment wiring, not behaviour: it is stripped from the identity like `signal`/`taskId`/`sessionId` (`workflow.ts`). ② The background lane's park family read `background.checkpointStore` (the tool option) for eligibility, TaskStop arbitration, compensation and claim rollback while the child now parks in the inherited seat — with two different instances a stop reported a phantom resume while the row stayed parked. The lane now addresses the store the child was HANDED (`ctx.checkpointStoreForChildren`, none for `"disabled"`); the option is read only when no host seat exists (a tool invoked outside a Runner task — its JSDoc says so). Round 2 (two more REAL, both fixed and pinned): ③ the observer sidecar (`observerSpec`, engine-built like every child) never carried the seat — a `"disabled"` parent's observer could park in the deployment store; it now carries the seat by the same spread. ④ the parked resume's config was rebuilt from the drive's ctx (the bare `/decide` ctx hands no seat), so with no Runner store the resume reported `no CheckpointStore wired` while the checkpoint stayed pending — the resume config now binds the store the lane arbitrates in (`checkpointStore: resumeRest.checkpointStore ?? childParkStore`; an explicit `"disabled"` still refuses). Consequence pinned in `agent-park-jian4-durableapproval-forwarding.test.ts` (RB-482 #31): a `background.checkpointStore` facade that KEEPS throwing now fails the drive loudly (rolled back, row still parked and redeemable) instead of proceeding through a second handle; the disclose-and-proceed shape is the transient (throw-once) form. Round 3 (one adopted, one refused): ⑤ a `background.checkpointStore` naming a DIFFERENT store than the host seat let a bare `/decide` drive resume against the option while the row lived in the seat — it is now refused at the first spawn (`config.invalid_checkpoint_store`, the checkpoint-seat refusal family; same instance or unset option both spawn). ⑥ REFUSED: "keep legacy journal keys" — `workflowAgentCallKey` documents that a spec-shape change moves every key and the resume of a pre-upgrade journal RE-RUNS live rather than replaying (design/164, declared); a compatibility key is the compat layer this codebase does not carry. **Upgrade note (@server):** a workflow journal written by ≤7.13.0 whose child specs carried `checkpointStore` (a `"disabled"` host, or a baseline-pinned store) misses on resume under 7.14.0 and its suffix re-runs live — finish or discard such parked workflow runs before upgrading if their children hold external writes. Not fixed, recorded: a caller tool whose non-string name is a `BigInt` AND whose `modelGate` class removes it for the selected model hits `JSON.stringify` in the model-gate disclosure before the roster-bound door (a second door would add a rule; the proper fix is to make the declaration door the FIRST reader).
50
+ - **Type keys (BREAKING on two exported types, no runtime seam).** `ToolExecuteContext.checkpointStoreDisabledForChildren?: true` → `checkpointStoreForChildren?: CheckpointStore | "disabled"` (Runner-filled whenever the run has a seat — an ordinary run on a deployment store now carries the store object where it carried no key; a run with no store anywhere carries no key, as before); `RunWorkflowToolDeps.parentCheckpointStoreDisabled?: boolean` → `parentCheckpointStore?: CheckpointStore | "disabled"`. `SubagentToolOptions.background.checkpointStore` keeps its key with a narrower meaning (read only without a host seat). Phase Inputs: `PrepareInheritedGateInput` / `PrepareCapsAndWorkflowInput` gain `checkpointSeat`, `PrepareQuestionFaceInput` gains `checkpointStore` (the question face no longer resolves its own; its `spec`/`deps` Picks drop `checkpointStore`).
51
+ - **Observable (pinned both ways).** Host store on the SPEC only: background child parks (pending row, `sourceTaskId` = the child session, zero notices); workflow child parks (wa* row `parked` + token, zero notices). Host `"disabled"` + deployment store: neither lane parks, ONE `delegation.ask_unresolvable` with `parkLaneExisted:false`. Host unset + deployment store: byte-for-byte the #642 six-grid (both lanes park; the ctx seat IS the deployment store object). Host seat = store A while `background.checkpointStore` = B: the child parks in A, TaskStop expires A's checkpoint (`killed`), B never sees a row.
52
+ - **Fail-open reverse check — which parent state lets a child be WIDER than its parent?** None: a `"disabled"` parent presses on the subtree; a parent with a store hands the child the same store; a parent with NO seat leaves the child exactly where it was before this change (its own door, its runner's deployment — the deployment's authored topology, pinned by the 件4 E2E chain).
53
+ - Pins: `test/backlog683-child-inherits-resolved-checkpoint-seat.test.ts` (①②③④ real-run through both lanes + the two review findings), `test/checkpoint-store-null.test.ts` (the three Task lanes + the workflow primitives + the Runner-filled seat, four seat states), `test/run-workflow-tool.test.ts`, `test/prepare-inherited-gate-transcript.test.ts`, `test/prepare-caps-and-workflow.test.ts`.
54
+ - **Not this car:** the teacher/student helper legs (`teacher.ts`) keep copying only the OFF switch — those legs cannot carry a pause (they read `r.result` and release the session), so a store must NOT reach them; their rule is stated there. Observer sidecar legs carry the seat since round 2 (see above).
55
+
56
+ ### Fix — a caller tool without a string `name` is refused by name, not crashed on (#684; test [6896] G-12)
57
+ - `rosterMemberBoundProblem` (the ONE roster-bound judge, `tool-roster.ts`) now judges the name's TYPE first: a member whose `name` is not a string (a class instance minted without an own `name`, `null`, a number) returns `config.tool_roster_bound` with `tool has no string name (got <typeof>)` — the declaration door (`prepare-safety-scan.ts`) and the row mint both read it, so the refusal is typed wherever the entry enters. Previously the door threw a bare `TypeError: Cannot read properties of undefined (reading 'length')`. The door's own frame is now `A caller tool cannot ride a roster row: <the judge's sentence>` (the judge's sentence already names the tool; the frame no longer re-reads `t.name`). Pin: `test/backlog684-tool-without-name-refused.test.ts` (+ positive control: the same class with an own `name` mounts).
58
+ ### Behaviour / wire — a stall watchdog is no longer reported as a dropped connection (#682 臂②; @cli @server @test)
59
+ - **`BrainRetryErrClass` +`stall` (closed set +1, additive).** `connect_refused | transport | stall | rate_limit | server | http | output_cap`. `stall` says NOTHING was observed on the link: this process cancelled its own request because one of its stall watchdogs reached its deadline. `transport` keeps its old meaning minus that case — something that happened TO the call. The split is about WHO GAVE UP, and claims nothing about the remedy: a blackholed link (no reset, no close) also produces silence, and silence is all a watchdog measures, so a `stall` may still turn out to be a network fault — what it always states truthfully is that the request was cancelled here, at a configured deadline, instead of lost out there.
60
+ - **The mid-stream retry announcement now belongs to the FAILURE, not to the tier.** Both re-sending tiers (B — only thinking streamed; C — nothing substantive) read one mapping, `midstreamRetryAnnouncement` (`src/brain/stream-engine.ts`). A watchdog cut announces `errClass:"stall"` with `detail` naming the watchdog, the deadline it fired at and the knob that arms it — `first-token watchdog fired after 120000ms (firstTokenTimeoutMs), retrying` / `stream idle watchdog fired after 90000ms (idleTimeoutMs), retrying`. A real mid-stream link failure still announces `errClass:"transport"` / `connection lost, reconnecting`. Before: BOTH tiers wrote the connection wording for both species, so a gateway whose prefill legitimately ran past the first-token deadline reported `transport` + "connection lost, reconnecting" and sent the operator looking for a network fault. The operator-facing `brain.retry` telemetry reads the same bucket as the user-facing status frame, as it did before.
61
+ - **Unchanged:** `phase` stays `reconnecting` for both species (the brain IS opening a fresh request); the `waiting_first_token` frame (`elapsedMs` / `timeoutMs`) is untouched; the tier-A partial-finalize warning keeps its CC-verbatim wording; the CONNECT-leg timeout keeps `errClass:"transport"` (a different watchdog and a different knob — not in this change). No watchdog default value moves *in this section* — the first-token default is raised by 臂① below, and nothing here depends on its value.
62
+ - **Downstream.** `@cli` `@server`: `errClass` gained a member — a renderer that switches on it must add a `stall` arm (the old wording arrives under a new class, so a `transport`-keyed branch stops firing for watchdog cuts); a consumer keying its "connection lost" prose off `detail` must re-read it. `@test`: the watchdog-cut retry frame's `detail` and `errClass` are both new text.
63
+ - Pins: `test/backlog682-stall-retry-announcement.test.ts` — ① first-token watchdog ⇒ tier C, `stall` + the watchdog/deadline/knob prose; ② idle watchdog ⇒ tier C (dangling tool call), same shape; ③ 反控 a real mid-stream link failure ⇒ `transport` + "connection lost, reconnecting"; ④ tier B (thinking-only) reads the SAME mapping, on tier B's own budget.
64
+
65
+ ### Behaviour — the first-token stall watchdog's default rises 120s → 600s (#682 臂①; ruling A; @server @cli @test)
66
+ - `STALL_FIRST_TOKEN_MS` (`src/brain/timeout.ts`), the ADVISORY per-call first-token bound the Runner supplies on every provider call, goes from `120_000` to `600_000`. The old value rested on a reason that is false for a real class of backend: a self-hosted server working through a long prefill emits literally nothing for 2–3 minutes and is perfectly healthy, so the watchdog cancelled real work and the re-send burned the same prefill again. 600s is the same order of magnitude as the reference implementation's own request timeout; the shape the bound exists for (a gateway that accepted the request and then died) is still caught, just later, and the task's own hard abort remains the outer bound.
67
+ - **Unchanged:** `STALL_CONNECT_MS` (30s — a different segment, the headers wait) and `STALL_IDLE_MS` (90s — silence after output started); the ADVISORY priority chain (a brain's construction-time `firstTokenTimeoutMs`, including an explicit `0`, still wins — a raised default is not a raised floor); the Runner's unconditional per-call supply.
68
+ - **The outer watchdog moved with it — `WORKFLOW_AGENT_STALL_MS` is now DERIVED (180_000 → `STALL_CONNECT_MS + STALL_FIRST_TOKEN_MS` = 630_000; #693).** `runWorkflow`'s per-agent progress watchdog is a COARSER instrument than the engine's re-send: it aborts the attempt and restarts the whole agent on a fresh session, so tool effects from before the stall can be repeated. At 120s the engine acted first in the ordinary case; raising the engine's bound past 180s inverted that systematically, and the same byte-silent provider call produced two agent runs on two sessions instead of one run with a re-sent call (measured). Deriving it means moving either engine bound moves this one too. A deployment that wants the old aggression passes `stallMs` explicitly (still honoured). CC divergence: `MTy` is 180000 upstream, and this is now a sema value — the alternative is an engine default whose own outer layer cancels it.
69
+ - **Read the sum as a floor, NOT as a proof of ordering (measured, adversarial round).** The two clocks do not start together and the outer one does not reset per inner retry, so it can still fire first: it starts before the Runner prepares the attempt (an execution-env factory, a hook, a store read all run inside it and outside the engine's window), while the first-token timer only starts once headers arrive — preparation plus connect time is exactly the margin by which the inner deadline can land later. Two silent provider attempts exceed the outer window by construction. The derivation removes the case that was GUARANTEED to invert; it does not make inversion impossible, and a deployment wanting a real guarantee sets `stallMs` from its own preparation budget. Also note the `run_workflow` TOOL's own whole-workflow cap (`totalTimeoutMs`, default 600_000) is now SMALLER than this window, so under that tool the total cap is what a long stall meets first.
70
+ - **A second meaning had to be split off it: `WORKFLOW_THROTTLE_SLOW_MS` (new, 90_000).** The throttle-shaped-degradation predicate read `stallMs * 0.5` — CC's own spelling, and exactly 90s only while the window was CC's 180_000. Deriving the window silently moved that threshold to 315s, so a 2-minute empty answer stopped earning its 45s-sleep-and-retry. The threshold is now its own constant at CC's value, floored by half the stall window (`min(stallMs / 2, 90_000)`) so a deployment that shortens `stallMs` still tightens it. Exported from `src/orchestration/workflow.ts`; NOT added to the package export surface (`handoff:diff` 0/0). The existing throttle pins derived their own durations from the stall constant and therefore moved with the regression — they now use a fixed 120_000.
71
+ - **What 600s still does NOT buy you (measured; disclosed, not fixed).** On Node's own `fetch` — the default transport for both adapters — undici destroys a body that has produced no bytes for 300s (`bodyTimeout`, default `300e3`), so byte-silence past 300s ends the attempt there and arrives as `errClass:"transport"` (correctly — the transport did cut it). A first token at 400s is therefore still unreachable on default Node fetch; the ruling's target case (a 2–3 minute prefill) sits inside 300s and is fully covered. **The seat to lift it exists:** `fetchImpl` on both `createOpenAIBrain` / `createAnthropicBrain` configs takes any `fetch`, so a deployment needing the full 600s supplies one built on an undici `Agent` with its own `bodyTimeout`. Filed as #694.
72
+ - **Downstream.** `@server`: mirror the catalog default for the settings key that steers this (`MODEL_FIRST_TOKEN_TIMEOUT_MS`) in 7.71.0. `@cli`: a deployment that set that key to work around the old bound can drop the override. `@test`: a run against a slow-prefill backend that used to fail `[network] first-token timeout after 120000ms` now waits.
73
+ - Pins: `test/backlog682-stall-retry-announcement.test.ts` 臂① (the supplied default is 600s, both sibling legs untouched; 反控 a construction-time `firstTokenTimeoutMs` still wins) and ① (the workflow window IS the derived sum; a byte-silent first provider call recovers in ONE agent run with TWO provider calls; 反控 an explicit `stallMs` below the engine's bound restarts the agent instead and is still honoured). Re-pinned: `test/walltime-stall-watchdog.test.ts` 量纲 sanity, `test/stream-options-forwarding.test.ts` (both per-call supply legs), `test/workflow-resilience.test.ts` (the window and the stall-exhaustion message read the constant instead of a literal 180000; the five throttle legs now spend a FIXED 120_000 so they stop moving with the threshold they test, plus two BOUNDARY legs either side of the exact threshold — without them the duration condition could be deleted outright and the suite stayed green).
74
+
75
+ ### Behaviour — the two output-length knobs say so when they discard a value (#690)
76
+ - `BASH_MAX_OUTPUT_LENGTH` (non-finite / ≤ 0) and `TASK_MAX_OUTPUT_LENGTH` (not a plain positive integer) fell back to their built-in defaults (30000 / 32000 characters) in SILENCE, with no written reason for the silence — while their sibling `MAX_MCP_OUTPUT_TOKENS`, same shape and same kind of resolver, already announced. Both now emit the sibling's line on the same process-level channel, once per deployment fact: `BASH_MAX_OUTPUT_LENGTH=25k was ignored — it is not a positive whole number of characters. Using 30000 instead.` For every value class the old resolvers already rejected this adds loudness and nothing else — the number in force does not move. One further class DOES move, and it is described in the next bullet.
77
+ - **One value class DOES change, and it is a bad-value class.** `BASH_MAX_OUTPUT_LENGTH` now requires a positive WHOLE number — the rule its own announcement states. Before, `Number.isFinite(raw) && raw > 0` let a fraction through to `Math.floor`, so `BASH_MAX_OUTPUT_LENGTH=0.5` resolved to a budget of ZERO and the shell's clip returned the empty string for a command that had already run and produced output — silently, and on the `TaskOutput` leg with no recovery file either. `0.5` / `.5` / `1e-3` / `1.5` now announce and take the 30000 default. Every whole-number value, the ceiling clamp, and the absence rule are untouched (adversarial round; found by the independent reviewer).
78
+ - Absence stays silent — unset, empty, or blank is not a value (the written exception the shell's timeout knobs already state).
79
+ - The announcer moved out of `src/core/mcp.ts` into `src/core/env-knob-announce.ts` (`announceEnvKnob`) so the outlet is a property of the shape — a process-level env knob whose resolver holds no structured notice seat — rather than of whichever domain built it first. No public export changes.
80
+ - The shared announcer is swallow-guarded (the posture every other advisory sink here already holds), which the MCP knobs inherit: a host that replaces `console.warn` with a logger that throws, or a `Console` built with `ignoreErrors:false` on a full stream, now costs the announcement and nothing else. Unguarded, it would have thrown out of the shell's output clip — which runs AFTER the command ran — destroying a completed command's result and inviting a re-run of work whose effects had already landed (adversarial round 2).
81
+ - Pins: `test/backlog690-output-length-knob-loud.test.ts` (per knob: four/five bad values ⇒ one line naming knob, raw text and the default in force + the second read stays silent; 反控 a legal value, a clamped-but-legal value, and absence ⇒ zero lines).
82
+
3
83
  ## 7.13.0 — 2026-09-10
4
84
 
5
85
  ### Narrowing — a run that has already given its answer is no longer cancellable (#677; ruling 「最后一次 `turn_end` 后 run 不可取消:三轴同律;窗内停机终态记 completed;`interrupt()` 改有明确回执」; sema form, no CC 2.1.250 mirror — DV-677-1; @server @cli @test)
@@ -650,15 +650,10 @@ export interface SubagentToolOptions {
650
650
  * writes a durable row (restart-surviving TaskOutput reads; the S1b lifecycle flip keys off the
651
651
  * same wiring). Absent ⇒ pre-151 in-memory-only lifecycle, unchanged. */
652
652
  agentStore?: import("../core/background-agent-store.js").BackgroundAgentStore;
653
- /**
654
- * design/153 §7.3 the durable-approval checkpoint store, for the PARK lane's compensation and
655
- * arbitration (expire on a lost park CAS the no-orphans posture; the in-process TaskStop
656
- * arbitration closure). MUST be the SAME instance as `RunnerDeps.checkpointStore` (the child's
657
- * suspend mints its checkpoint there; a different instance could not see the token — the F-6
658
- * dual-wiring hazard, documented symptom: parks succeed but stops/reconciliation read "missing").
659
- * Absent ⇒ a durably-suspending bg child keeps the pre-153 lifecycle (settled `failed` with
660
- * `unexpected.suspended`), byte-for-byte.
661
- */
653
+ /** design/153 §7.3 — the park lane's checkpoint store where no host seat rides (a tool invoked outside a Runner task, and the
654
+ * bare /decide drive of a parked child). Inside a task the child parks in the seat it was HANDED (`ToolExecuteContext.
655
+ * checkpointStoreForChildren`); an option naming a different store is refused at spawn (`config.invalid_checkpoint_store`).
656
+ * Absent with no host seat a durably-suspending bg child keeps the pre-153 lifecycle. */
662
657
  checkpointStore?: import("../core/checkpoint-store.js").CheckpointStore;
663
658
  /**
664
659
  * design/153 §7.3 (r4 F-09) — the CAPABILITY protocol that attests the child's transcript
@@ -1897,6 +1897,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1897
1897
  ...(observerDef.systemPrompt !== undefined && !resume ? { systemPrompt: observerDef.systemPrompt } : {}),
1898
1898
  ...(ctx.principal !== undefined ? { principal: ctx.principal } : {}),
1899
1899
  ...(ctx.getApiKeyAndHeaders !== undefined ? { getApiKeyAndHeaders: ctx.getApiKeyAndHeaders } : {}),
1900
+ ...(ctx.checkpointStoreForChildren !== undefined ? { checkpointStore: ctx.checkpointStoreForChildren } : {}),
1900
1901
  tools: [reportTool],
1901
1902
  ...(ctx.excludeTools !== undefined ? { excludeTools: [...ctx.excludeTools] } : {}),
1902
1903
  ...(ctx.deferTools !== undefined ? { deferTools: [...ctx.deferTools] } : {}),
@@ -2154,7 +2155,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2154
2155
  ...(def?.memory ? { memory: def.memory } : {}),
2155
2156
  ...(def?.skills?.length ? { skills: def.skills } : {}),
2156
2157
  ...(ctx.principal !== undefined ? { principal: ctx.principal } : {}),
2157
- ...(ctx.checkpointStoreDisabledForChildren === true ? { checkpointStore: "disabled" } : {}),
2158
+ ...(ctx.checkpointStoreForChildren !== undefined ? { checkpointStore: ctx.checkpointStoreForChildren } : {}),
2158
2159
  ...(childOnAsk !== undefined ? { onAsk: childOnAsk } : {}),
2159
2160
  ...(ctx.onQuestion !== undefined ? { onQuestion: ctx.onQuestion } : {}),
2160
2161
  ...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
@@ -2922,6 +2923,9 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2922
2923
  }
2923
2924
  if (wantsBackground) {
2924
2925
  const bg = opts.background;
2926
+ if (ctx.checkpointStoreForChildren !== undefined && ctx.checkpointStoreForChildren !== "disabled" && bg.checkpointStore !== undefined && bg.checkpointStore !== ctx.checkpointStoreForChildren)
2927
+ throw configError("background.checkpointStore is not the store this run's children park in (the host's resolved checkpoint seat, TaskSpec/RunnerDeps.checkpointStore) — wire the one store, or leave the option unset and let the seat ride.", "config.invalid_checkpoint_store");
2928
+ const childParkStore = ctx.checkpointStoreForChildren === undefined ? bg.checkpointStore : ctx.checkpointStoreForChildren === "disabled" ? undefined : ctx.checkpointStoreForChildren;
2925
2929
  const notify = ctx.onTaskNotification ?? bg.notify;
2926
2930
  const abort = new AbortController();
2927
2931
  const reviveRow = reviveClaim?.row;
@@ -3030,7 +3034,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3030
3034
  initialStatus: "parked",
3031
3035
  parkedCheckpointToken: parkedResume.ticket.token,
3032
3036
  resolveParkedStop: async () => {
3033
- const cpS = bg.checkpointStore;
3037
+ const cpS = childParkStore;
3034
3038
  if (cpS === undefined)
3035
3039
  return false;
3036
3040
  const cp = (await cpS.get(parkedResume.ticket.token));
@@ -3173,7 +3177,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3173
3177
  }
3174
3178
  const forwardDurableApproval = ctx.durableApprovalForChildren !== undefined &&
3175
3179
  bg.agentStore !== undefined &&
3176
- bg.checkpointStore !== undefined &&
3180
+ childParkStore !== undefined &&
3177
3181
  bg.ensureChildSessionDurable !== undefined;
3178
3182
  const bgSpecBase = {
3179
3183
  ...buildChildSpec(abort.signal),
@@ -3279,7 +3283,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3279
3283
  };
3280
3284
  const driveParkedResume = async () => {
3281
3285
  const pr = parkedResume;
3282
- const claimStores = { agentStore: bg.agentStore, checkpointStore: bg.checkpointStore };
3286
+ const claimStores = { agentStore: bg.agentStore, checkpointStore: childParkStore };
3283
3287
  const rollbackAndThrow = async (e) => {
3284
3288
  let disp = "retry";
3285
3289
  try {
@@ -3303,9 +3307,9 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3303
3307
  const code = e.code;
3304
3308
  if (code === "not_found") {
3305
3309
  try {
3306
- const cp = (await bg.checkpointStore.get(pr.ticket.token));
3310
+ const cp = (await childParkStore.get(pr.ticket.token));
3307
3311
  if (cp !== null)
3308
- await bg.checkpointStore.expire(pr.ticket.token, cp.scope);
3312
+ await childParkStore.expire(pr.ticket.token, cp.scope);
3309
3313
  }
3310
3314
  catch {
3311
3315
  }
@@ -3333,10 +3337,10 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3333
3337
  };
3334
3338
  let childResult;
3335
3339
  try {
3336
- const { objective: _o, sessionId: _s, ...resumeConfig } = bgSpec;
3340
+ const { objective: _o, sessionId: _s, ...resumeRest } = bgSpec, resumeConfig = { ...resumeRest, checkpointStore: resumeRest.checkpointStore ?? childParkStore };
3337
3341
  if (resumeConfig.durableApproval === undefined) {
3338
3342
  try {
3339
- const cp0 = (await bg.checkpointStore.get(pr.ticket.token));
3343
+ const cp0 = (await childParkStore.get(pr.ticket.token));
3340
3344
  const rec = cp0?.durableApproval;
3341
3345
  if (rec !== undefined && typeof rec.scope === "string" && rec.scope.length > 0) {
3342
3346
  resumeConfig.durableApproval = { scope: rec.scope, ...(typeof rec.ttlMs === "number" ? { ttlMs: rec.ttlMs } : {}) };
@@ -3369,7 +3373,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3369
3373
  if (childResult.terminal.kind === "failed") {
3370
3374
  let reopened = false;
3371
3375
  try {
3372
- const cp = (await bg.checkpointStore.get(pr.ticket.token));
3376
+ const cp = (await childParkStore.get(pr.ticket.token));
3373
3377
  reopened = cp !== null && cp.status === "pending";
3374
3378
  }
3375
3379
  catch {
@@ -3379,7 +3383,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3379
3383
  bg.registry.parkBackgroundAgent(taskId, {
3380
3384
  checkpointToken: pr.ticket.token,
3381
3385
  resolveStop: async () => {
3382
- const cpS = bg.checkpointStore;
3386
+ const cpS = childParkStore;
3383
3387
  const cp = (await cpS.get(pr.ticket.token));
3384
3388
  if (cp === null)
3385
3389
  return false;
@@ -3444,7 +3448,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3444
3448
  let unparkedPause;
3445
3449
  if (isDurablePause(child.terminal)) {
3446
3450
  const pausedToken = child.terminal.token;
3447
- const cpStore = bg.checkpointStore;
3451
+ const cpStore = childParkStore;
3448
3452
  const expireByStoreScope = async (token2) => {
3449
3453
  try {
3450
3454
  const cp0 = (await cpStore.get(token2));
@@ -3476,7 +3480,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3476
3480
  checkpointToken: token,
3477
3481
  seq: seqAtSettle ?? 1,
3478
3482
  resolveStop: async () => {
3479
- const cpS = bg.checkpointStore;
3483
+ const cpS = childParkStore;
3480
3484
  const cp = (await cpS.get(token));
3481
3485
  if (cp === null)
3482
3486
  return false;
@@ -409,7 +409,7 @@ async function runTeacherCore(runner, studentSpec, teacher) {
409
409
  sessionId: sid ?? "aborted",
410
410
  terminal: { kind: "failed", message: "student run did not settle promptly after stuck-abort" },
411
411
  result: "",
412
- stats: { turns: 0, tokens: 0 },
412
+ stats: { turns: 0, tokens: 0, usageMissing: true },
413
413
  });
414
414
  const abortFallback = (sid) => {
415
415
  let timer;
@@ -267,6 +267,29 @@ export declare function resolveReasoning(requested: ThinkingLevel, model: {
267
267
  * valid member); individually invalid MEMBERS (garbage strings, sparse holes, `"off"` — not a legal
268
268
  * member of the 6-tier compat effort sets) are dropped, so they can never become a clamp target.
269
269
  */
270
+ /**
271
+ * #692 C-c — the effort tiers that actually REACH THE WIRE for this model: the table {@link resolveEffort} clamps
272
+ * against, keyed by the model's API family exactly as {@link resolveReasoning} dispatches, gated by the request
273
+ * entry predicate (a `reasoning: false` model accepts no tier) and minus the tiers the model's `thinkingLevelMap`
274
+ * suppresses (`null` = no effort value on the wire) — the same arms, so a host reading this and the trace's
275
+ * `effective` can never disagree:
276
+ * - `anthropic-messages` with declared `compat.effortLevels` → that set (the `output_config.effort` lane);
277
+ * without it → `undefined` (the BUDGET lane is a token gradient, there is no tier set to clamp to);
278
+ * - the Responses family and the completions family → `compat.reasoningEffortLevels` sanitized
279
+ * (invalid members dropped, an empty/garbage declaration reads as undeclared) or the conservative
280
+ * {@link DEFAULT_EFFORT_LEVELS}; `supportsReasoningEffort: false` (openrouter excepted, whose unified
281
+ * knob ignores it) and the binary formats (`qwen` / `zai` / `qwen-chat-template`) → `undefined` (the tier
282
+ * is not honored as a gradient on that wire).
283
+ * `undefined` therefore means "this wire clamps no tier set", never "unknown": a host that offers a person the
284
+ * supported tiers reads THIS, per model, instead of a global default it cannot vouch for. Per API, not per
285
+ * model id: the acceptance set is a property of the endpoint's request schema; `Model` grows no key for it.
286
+ */
287
+ export declare function reasoningEffortLevelsOf(model: {
288
+ api?: string;
289
+ reasoning?: boolean;
290
+ compat?: unknown;
291
+ thinkingLevelMap?: Readonly<Partial<Record<ThinkingLevel, string | null>>>;
292
+ }): readonly ThinkingLevel[] | undefined;
270
293
  export declare function resolveEffort(requested: ThinkingLevel, allowed?: readonly ThinkingLevel[]): ReasoningResolution;
271
294
  /**
272
295
  * Resolve a requested intensity for a binary enable-only endpoint (qwen / zai / qwen-chat-template): the
@@ -91,8 +91,7 @@ function dispatchReasoning(requested, model, facts) {
91
91
  if (BINARY_FORMATS.has(format)) {
92
92
  return { ...resolveBinary(requested), format, endpoint };
93
93
  }
94
- const supportsEffort = compat.supportsReasoningEffort ?? true;
95
- if (!supportsEffort && format !== "openrouter") {
94
+ if (!completionsEffortHonored(compat, format)) {
96
95
  return { requested, effective: requested, graded: false, clamped: false, format, endpoint };
97
96
  }
98
97
  const mint = mintEffortWireValue(requested, model, compat.reasoningEffortLevels);
@@ -101,9 +100,41 @@ function dispatchReasoning(requested, model, facts) {
101
100
  }
102
101
  return { ...mint.resolution, format, endpoint };
103
102
  }
104
- export function resolveEffort(requested, allowed = DEFAULT_EFFORT_LEVELS) {
103
+ export function reasoningEffortLevelsOf(model) {
104
+ if (!model.reasoning)
105
+ return undefined;
106
+ const compat = (model.compat ?? {});
107
+ if (model.api === "anthropic-messages") {
108
+ const declared = declaredEffortLevels(compat.effortLevels);
109
+ return declared === undefined ? undefined : sanitizeEffortLevels(declared);
110
+ }
111
+ let clampSet;
112
+ if (model.api !== undefined && RESPONSES_APIS.has(model.api)) {
113
+ if (compat.supportsReasoningEffort === false)
114
+ return undefined;
115
+ clampSet = sanitizeEffortLevels(compat.reasoningEffortLevels);
116
+ }
117
+ else {
118
+ const format = compat.thinkingFormat ?? "openai";
119
+ if (BINARY_FORMATS.has(format))
120
+ return undefined;
121
+ if (!completionsEffortHonored(compat, format))
122
+ return undefined;
123
+ clampSet = sanitizeEffortLevels(compat.reasoningEffortLevels);
124
+ }
125
+ const honored = clampSet.filter((lvl) => model.thinkingLevelMap?.[lvl] !== null);
126
+ return honored.length > 0 ? honored : undefined;
127
+ }
128
+ function completionsEffortHonored(compat, format) {
129
+ const supportsEffort = compat.supportsReasoningEffort ?? true;
130
+ return !!supportsEffort || format === "openrouter";
131
+ }
132
+ function sanitizeEffortLevels(allowed) {
105
133
  const declared = Array.isArray(allowed) ? allowed.filter((lvl) => isThinkingLevel(lvl) && lvl !== "off") : [];
106
- allowed = declared.length > 0 ? declared : DEFAULT_EFFORT_LEVELS;
134
+ return declared.length > 0 ? declared : DEFAULT_EFFORT_LEVELS;
135
+ }
136
+ export function resolveEffort(requested, allowed = DEFAULT_EFFORT_LEVELS) {
137
+ allowed = sanitizeEffortLevels(allowed);
107
138
  if (requested === "off" || allowed.includes(requested)) {
108
139
  return { requested, effective: requested, graded: true, clamped: false };
109
140
  }
@@ -246,6 +246,11 @@ async function sleepAnnouncingRetry(totalMs, signal, frame) {
246
246
  return;
247
247
  }
248
248
  }
249
+ function midstreamRetryAnnouncement(failure) {
250
+ return failure.kind === "stalled"
251
+ ? { errClass: "stall", detail: `${failure.watchdog} watchdog fired after ${failure.timeoutMs}ms (${failure.knob}), retrying` }
252
+ : { errClass: "transport", detail: "connection lost, reconnecting" };
253
+ }
249
254
  export function runStreamingBrain(args) {
250
255
  const { model, doFetch, signal, config, httpLabel, buildRequest, makeParser, stallTimeouts } = args;
251
256
  const out = createAssistantMessageEventStream();
@@ -471,7 +476,7 @@ export function runStreamingBrain(args) {
471
476
  const decoder = new TextDecoder();
472
477
  let buffer = "";
473
478
  let firstTokenSeen = false;
474
- let firstTokenTimedOut = false;
479
+ let firedFirstTokenDeadlineMs;
475
480
  let ftTimer;
476
481
  let waitTimer;
477
482
  const clearWaitTimer = () => {
@@ -482,7 +487,7 @@ export function runStreamingBrain(args) {
482
487
  };
483
488
  if (firstTokenTimeoutMs && firstTokenTimeoutMs > 0) {
484
489
  ftTimer = setTimeout(() => {
485
- firstTokenTimedOut = true;
490
+ firedFirstTokenDeadlineMs = firstTokenTimeoutMs;
486
491
  clearWaitTimer();
487
492
  void reader.cancel().catch(() => undefined);
488
493
  }, firstTokenTimeoutMs);
@@ -492,7 +497,7 @@ export function runStreamingBrain(args) {
492
497
  const scheduleWaitFrame = (delayMs) => {
493
498
  waitTimer = setTimeout(() => {
494
499
  waitTimer = undefined;
495
- if (firstTokenSeen || firstTokenTimedOut || Date.now() - waitStartedAt >= firstTokenTimeoutMs)
500
+ if (firstTokenSeen || firedFirstTokenDeadlineMs !== undefined || Date.now() - waitStartedAt >= firstTokenTimeoutMs)
496
501
  return;
497
502
  announcedWait = true;
498
503
  emitWaitFrame({
@@ -507,16 +512,16 @@ export function runStreamingBrain(args) {
507
512
  scheduleWaitFrame(WAITING_FIRST_TOKEN_AFTER_MS);
508
513
  }
509
514
  }
510
- let idleTimedOut = false;
515
+ let firedIdleDeadlineMs;
511
516
  let idleTimer;
512
517
  const bumpIdle = () => {
513
518
  if (!idleTimeoutMs || idleTimeoutMs <= 0)
514
519
  return;
515
- idleTimedOut = false;
520
+ firedIdleDeadlineMs = undefined;
516
521
  if (idleTimer)
517
522
  clearTimeout(idleTimer);
518
523
  idleTimer = setTimeout(() => {
519
- idleTimedOut = true;
524
+ firedIdleDeadlineMs = idleTimeoutMs;
520
525
  void reader.cancel().catch(() => undefined);
521
526
  }, idleTimeoutMs);
522
527
  };
@@ -525,7 +530,7 @@ export function runStreamingBrain(args) {
525
530
  if (firstTokenSeen)
526
531
  return;
527
532
  firstTokenSeen = true;
528
- firstTokenTimedOut = false;
533
+ firedFirstTokenDeadlineMs = undefined;
529
534
  if (ftTimer) {
530
535
  clearTimeout(ftTimer);
531
536
  ftTimer = undefined;
@@ -615,11 +620,23 @@ export function runStreamingBrain(args) {
615
620
  clearTimeout(idleTimer);
616
621
  idleTimer = undefined;
617
622
  }
618
- if (!failure && firstTokenTimedOut) {
619
- failure = { kind: "stalled", err: new BrainError("network", `first-token timeout after ${firstTokenTimeoutMs}ms`, undefined, { apiError: true }) };
623
+ if (!failure && firedFirstTokenDeadlineMs !== undefined) {
624
+ failure = {
625
+ kind: "stalled",
626
+ watchdog: "first-token",
627
+ knob: "firstTokenTimeoutMs",
628
+ timeoutMs: firedFirstTokenDeadlineMs,
629
+ err: new BrainError("network", `first-token timeout after ${firedFirstTokenDeadlineMs}ms`, undefined, { apiError: true }),
630
+ };
620
631
  }
621
- if (!failure && idleTimedOut) {
622
- failure = { kind: "stalled", err: new BrainError("network", `stream idle timeout after ${idleTimeoutMs}ms`, undefined, { apiError: true }) };
632
+ if (!failure && firedIdleDeadlineMs !== undefined) {
633
+ failure = {
634
+ kind: "stalled",
635
+ watchdog: "stream idle",
636
+ knob: "idleTimeoutMs",
637
+ timeoutMs: firedIdleDeadlineMs,
638
+ err: new BrainError("network", `stream idle timeout after ${firedIdleDeadlineMs}ms`, undefined, { apiError: true }),
639
+ };
623
640
  }
624
641
  if (!failure) {
625
642
  buffer += decoder.decode();
@@ -641,22 +658,23 @@ export function runStreamingBrain(args) {
641
658
  throw failure.err;
642
659
  cleanup?.();
643
660
  cleanup = undefined;
661
+ const announcement = midstreamRetryAnnouncement(failure);
644
662
  if (snap.hasOnlyThinking) {
645
663
  if (thinkingRetries >= THINKING_RETRY_BUDGET)
646
664
  throw failure.err;
647
665
  thinkingRetries++;
648
666
  parser.sealForRetry();
649
667
  const delayMs = 100 * thinkingRetries;
650
- emitBrainTelemetry({ kind: "retry", attempt: thinkingRetries, phase: "midstream", errClass: "transport", nextDelayMs: delayMs });
668
+ emitBrainTelemetry({ kind: "retry", attempt: thinkingRetries, phase: "midstream", errClass: announcement.errClass, nextDelayMs: delayMs });
651
669
  announcedRetry = true;
652
670
  await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
653
671
  phase: "reconnecting",
654
- detail: "connection lost, reconnecting",
672
+ detail: announcement.detail,
655
673
  retryInSec: Math.ceil(remainingMs / 1000),
656
674
  retryInMs: remainingMs,
657
675
  attempt: thinkingRetries,
658
676
  maxRetries: THINKING_RETRY_BUDGET,
659
- errClass: "transport",
677
+ errClass: announcement.errClass,
660
678
  }));
661
679
  continue;
662
680
  }
@@ -664,16 +682,16 @@ export function runStreamingBrain(args) {
664
682
  throw failure.err;
665
683
  const delayMs = retryBackoffMs(baseDelay, attempt);
666
684
  attempt++;
667
- emitBrainTelemetry({ kind: "retry", attempt, phase: "midstream", errClass: "transport", nextDelayMs: delayMs });
685
+ emitBrainTelemetry({ kind: "retry", attempt, phase: "midstream", errClass: announcement.errClass, nextDelayMs: delayMs });
668
686
  announcedRetry = true;
669
687
  await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
670
688
  phase: "reconnecting",
671
- detail: "connection lost, reconnecting",
689
+ detail: announcement.detail,
672
690
  retryInSec: Math.ceil(remainingMs / 1000),
673
691
  retryInMs: remainingMs,
674
692
  attempt,
675
693
  maxRetries,
676
- errClass: "transport",
694
+ errClass: announcement.errClass,
677
695
  }));
678
696
  }
679
697
  }
@@ -38,17 +38,38 @@ export declare function resolveStallTimeoutMs(value: number | undefined, knob: s
38
38
  * - connect 30s: bounds the fetch→response-HEADERS wait — the one segment every post-headers
39
39
  * watchdog is blind to. On a streaming API the model's time is spent AFTER headers, so 30s with
40
40
  * none at all is a hung gateway, not a slow model.
41
- * - first-token 120s: bounds the headers→first-delta wait, NOT the model's thought (the first
41
+ * - first-token 600s: bounds the headers→first-delta wait, NOT the model's thought (the first
42
42
  * THINKING delta already counts as the first token, design/17 O5 — see
43
- * {@link BrainTimeoutConfig.firstTokenTimeoutMs}), so a slow reasoner is safe; 2 minutes with
44
- * literally NOTHING is a hung gateway.
43
+ * {@link BrainTimeoutConfig.firstTokenTimeoutMs}), so a slow reasoner is safe. The old 120s rested
44
+ * on "2 minutes with literally NOTHING is a hung gateway", which is FALSE for a real class of
45
+ * backend: a self-hosted server working through a long prefill emits nothing at all for 2–3
46
+ * minutes, and the watchdog cancelled it — burning the prefill and re-sending a request that then
47
+ * burned it again. A guardrail whose reason is false for healthy traffic is not a guardrail. 600s
48
+ * is the same order of magnitude as the reference implementation's own request timeout, and the
49
+ * shapes this bound exists for (a gateway that accepted the request and then died) are still
50
+ * caught, just later; the task's own hard abort remains the outer bound for everything else.
51
+ * HONEST CEILING (measured, adversarial round): this watchdog is not the only clock over a
52
+ * byte-silent stream, so 600s is the bound this engine ASKS FOR, not the silence a deployment is
53
+ * necessarily granted. On Node's own `fetch` — the default transport for both adapters, and the
54
+ * one a deployment gets by supplying no `fetchImpl` — undici destroys a body that has produced no
55
+ * bytes for 300s (`bodyTimeout`, default `300e3`), so byte-silence past 300s ends the attempt
56
+ * there and arrives as a read rejection reported `transport`. That report is CORRECT (the
57
+ * transport really did cut it) and the seat to change it exists: `fetchImpl` on both brain configs
58
+ * takes any `fetch`, so a deployment needing the full 600s passes one built on an undici
59
+ * `Agent`/`Dispatcher` with its own `bodyTimeout`. Named here so the next reader who measures ~300s
60
+ * of tolerance does not conclude this number is a lie.
61
+ * The OTHER clock that used to sit below this one is fixed rather than disclosed:
62
+ * {@link import("../orchestration/workflow.js").WORKFLOW_AGENT_STALL_MS} is now DERIVED from
63
+ * {@link STALL_CONNECT_MS} + this value, so the coarse agent-restart no longer sits UNDER a single
64
+ * silent provider call by construction. Move this number and that one follows. It is a floor, not
65
+ * a guarantee of ordering — see that constant's own comment for the cases that still invert.
45
66
  * - idle 90s: mid-stream silence after output started — a healthy stream emits steadily
46
67
  * (comfortably above any provider keep-alive cadence).
47
68
  * ADVISORY: a brain whose construction-time {@link BrainTimeoutConfig} sets the corresponding field
48
69
  * keeps it (deployment sovereignty; see the `StreamOptions.stallTimeouts` priority chain).
49
70
  */
50
71
  export declare const STALL_CONNECT_MS = 30000;
51
- export declare const STALL_FIRST_TOKEN_MS = 120000;
72
+ export declare const STALL_FIRST_TOKEN_MS = 600000;
52
73
  export declare const STALL_IDLE_MS = 90000;
53
74
  export interface BrainTimeoutConfig {
54
75
  /** Abort the request if `fetch` doesn't return response headers within this many ms. Retryable. */
@@ -15,7 +15,7 @@ export function resolveStallTimeoutMs(value, knob) {
15
15
  return value;
16
16
  }
17
17
  export const STALL_CONNECT_MS = 30_000;
18
- export const STALL_FIRST_TOKEN_MS = 120_000;
18
+ export const STALL_FIRST_TOKEN_MS = 600_000;
19
19
  export const STALL_IDLE_MS = 90_000;
20
20
  export function createConnectController(connectTimeoutMs, outerSignal) {
21
21
  const ac = new AbortController();