@sema-agent/core 7.43.0 → 7.44.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 (39) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/agents/subagent.d.ts +2 -1
  3. package/dist/brain/errors.d.ts +23 -2
  4. package/dist/brain/errors.js +26 -2
  5. package/dist/brain/stream-engine.d.ts +2 -1
  6. package/dist/brain/stream-engine.js +10 -9
  7. package/dist/core/checkpoint-store.d.ts +26 -13
  8. package/dist/core/checkpoint-store.js +4 -13
  9. package/dist/core/engine-notice.d.ts +16 -14
  10. package/dist/core/engine-notice.js +4 -3
  11. package/dist/core/governance-codes.d.ts +4 -3
  12. package/dist/core/governance-codes.js +1 -0
  13. package/dist/core/memory-engine/engine.d.ts +2 -2
  14. package/dist/core/memory-engine/engine.js +7 -14
  15. package/dist/core/memory-engine/file-backend.d.ts +2 -2
  16. package/dist/core/memory-engine/file-backend.js +5 -7
  17. package/dist/core/memory-engine/layout.d.ts +37 -4
  18. package/dist/core/memory-engine/layout.js +34 -5
  19. package/dist/core/runner/contracts.d.ts +1 -16
  20. package/dist/core/runner/runtask.js +1 -5
  21. package/dist/core/runner/steer-admission.d.ts +10 -11
  22. package/dist/core/runner/steer-admission.js +6 -8
  23. package/dist/core/runner/stream-steer-verb.d.ts +5 -4
  24. package/dist/core/runner/stream-steer-verb.js +6 -8
  25. package/dist/core/steer-replay.d.ts +46 -0
  26. package/dist/core/steer-replay.js +22 -0
  27. package/dist/core/task-event.d.ts +3 -2
  28. package/dist/core/task-stream.d.ts +12 -6
  29. package/dist/core/tool-errors.d.ts +2 -2
  30. package/dist/core/types.d.ts +2 -2
  31. package/dist/core/types.js +1 -1
  32. package/dist/index.d.ts +3 -2
  33. package/dist/index.js +3 -2
  34. package/dist/orchestration/workflow-types.d.ts +5 -0
  35. package/dist/orchestration/workflow.d.ts +3 -2
  36. package/dist/orchestration/workflow.js +14 -6
  37. package/package.json +1 -1
  38. package/test/export-surface.snapshot.json +11 -5
  39. package/wire-changes/core@7.44.0.json +469 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,76 @@
1
+ ## 7.44.0 — 2026-10-07
2
+
3
+ **Hard-breaking changes (the whole release):** one — #1362. **#1362 — What breaks:** the closed set `USER_STEER_UNDRAINED_CAUSES` and its type `UserSteerUndrainedCause` are renamed `USER_INPUT_UNDRAINED_CAUSES` / `UserInputUndrainedCause` — the same two words in the same order, no alias left behind. **Who goes red:** a host that imports either old name from the package root (a TypeScript build fails; plain JavaScript reads `undefined`), and a downstream table generated from or pinned to the old name. **How to fix:** import the new names; nothing else about the set changed.
4
+
5
+ **Package exports (the whole release):** +5 / −2 — +`PROXY_TUNNEL_REFUSED` (#1157), +`USER_INPUT_UNDRAINED_CAUSES`, +`UserInputUndrainedCause` (#1362), +`STEER_REPLAY_FACETS`, +`SteerReplayFacet` (#1374); −`USER_STEER_UNDRAINED_CAUSES`, −`UserSteerUndrainedCause` (#1362). **Closed sets (the whole release):** `UserSteerUndrainedCause` → `UserInputUndrainedCause` (renamed, members unchanged); new `SteerReplayFacet`; new `ScopeHomeForm`; the memory code registry +1 (`memory.scope_home_not_directory`). The paragraphs below state each ticket's own share.
6
+
7
+ **#1319 —** hard-breaking: none; closed sets: none; exports: none added or removed; the `WorkflowRun.parks` entry gains two optional members, `parkedBy` and `generation` (wire typeKeys +2, both optional).
8
+
9
+ **#1319 — behavior:** a workflow run record's `parks` entry names the run standing on the park and the generation of the call's park line; a recorded entry whose `parkedBy` / `generation` is malformed, or that carries one without the other, refuses the resume as a malformed entry (`workflow.park_binding_broken`).
10
+
11
+ **#1157 —** hard-breaking: none; closed sets: none added (`BrainRetryErrClass`, `BrainStatusPhase`, `BrainErrorCode`, `TerminalFailureDetail` unchanged; the `connect_refused` bucket now also names a proxy refusal). Exports: +1 / −0 — `PROXY_TUNNEL_REFUSED`. **Behavior narrowing:** one — a proxy that refuses the tunnel no longer gets the long retry ladder (see below).
12
+
13
+ **#1368 —** hard-breaking: none; closed sets: +1 — `ScopeHomeForm` (`symlink` | `file` | `other`, the `detail.form` of the new refusal below); the memory code registry +1 (`memory.scope_home_not_directory`, non-governance, control-plane IO). Exports: none added or removed. **Behavior:** a memory scope home that is not a real directory is refused with a code instead of the code-less control-plane error, and a coded store refusal's sentence carries no host path (it rides `detail.path`).
14
+
15
+ **#1362 / #1374 —** closed sets: `UserSteerUndrainedCause` → `UserInputUndrainedCause` (renamed, members unchanged); new `SteerReplayFacet` (`text`, `trusted`, `priority`, `actor`). Exports: +4 / −2 — +`USER_INPUT_UNDRAINED_CAUSES`, +`UserInputUndrainedCause`, +`STEER_REPLAY_FACETS`, +`SteerReplayFacet`; −`USER_STEER_UNDRAINED_CAUSES`, −`UserSteerUndrainedCause`. New optional member: `CheckpointError` `detail.differs`.
16
+
17
+ **#1362 / #1374 — behavior:** `task.user_followup_undrained` now carries `detail.cause` (`leg_end` from this engine) like `task.user_steer_undrained` (#1362); `steering.duplicate_input_id` now carries `detail.differs` on both faces, and a repeat that differs only in tier / trust / attribution is no longer told to re-issue under a fresh `inputId` (#1374). What counts as a replay is unchanged.
18
+
19
+
20
+ ### A workflow park entry names who stands on it — `parkedBy` / `generation` (#1319; additive record members) (@server @client-core @test)
21
+
22
+ - **Before (v7.43.x).** An entry carried `callKey` / `sessionId` / `token`, `originRunId` (the run whose journal holds the park's material — not the run standing on it) and `originUnconfirmed`. A same-token re-park whose journal append failed and an inherited park the run never reached left the SAME entry on the successor (measured on the base: `{ token, sessionId, originRunId: <prior run> }` in both), and nothing said which record of a resume chain is its tail — so a consumer routed a decision by the record's `agents[]` rows and redeemed it by `parks[]`, two readings of one question.
23
+ - **Now.** "Does this record's run hold this park" has ONE reading: `entry.parkedBy === record.id`.
24
+ - `parkAgentLeg` writes `parkedBy: <this run>` and `generation` in the synchronous stretch that sets the row `parked`, on both branches (a new token, a same-token re-park) and on every spawn lane (`ctx.agent`, `ctx.parallel` legs, `ctx.agentStream` — measured: the same entry shape on all three), before any journal append is awaited: a failed append leaves `originRunId` / `originUnconfirmed` as they were and moves neither member.
25
+ - `generation` is the generation of the call's inherited entry, else `0`: a call's first park in its chain is `0`; a park at a call whose entry the run inherited keeps that entry's generation — the same token again, **or a new token after the inherited one was driven** (C-R251: resetting a new token to `0` would tie with the earlier record's entry on the same child session and read as a fork).
26
+ - Resume admission carries `parkedBy` verbatim and adds one to `generation`, at the one site that establishes the inherited set (a recorded entry whose empty token or session another key cache completes keeps its pair; a half it does name that contradicts that cache is now a broken binding, `workflow.park_binding_broken`, where 7.43.x took the other cache's coordinate silently) — so an entry inherited and not reached names the run that parked it, and along a chain the record holding the highest generation on a session is the tail. Two records holding the same generation are a fork (one record resumed twice in turn); the engine admits both resumes (concurrent resumes are already excluded by the resume claim) and leaves the refusal to the consumer.
27
+ - The two members travel together. An entry written before they existed — an older engine's list, or keys derived from the journal / agent rows of a record with no list — carries neither after inheritance (nothing writes them onto it) and proves no holder; the new engine reaching such a park writes its own line at `generation: 0`.
28
+ - A recorded entry whose `parkedBy` is not a non-empty string, whose `generation` is not a non-negative integer, or that carries one without the other is a malformed entry: the resume is refused before anything is dispatched (`workflow.park_binding_broken`).
29
+ - **Who is hurt.** A record written by an older engine carries neither member: a consumer that routes by the new reading proves no holder for it (the serving layer answers its existing `decide.workflow_parks_unreadable` and drains such records before upgrading — its half, S-266). A store or tool that rewrites a `parks` entry and mangles a member (a column store turning `generation` into text) now has the next resume refused instead of carried. A probe that pinned the exact entry shape (the four established members) sees two more. A fork whose one branch drove the token while the other stood on it (R1 resumed as R2 and R2′; R2′ re-parks on T, R2 drives T and its child pauses on T′): both hold an entry on the session at generation 1, so the consumer's fork refusal answers the new card until one branch is resumed.
30
+ - **Compensation.** A reader that ignores unknown members needs nothing. A store that projects the record by column carries the two members verbatim (they are part of the `parks` entry, whose projection obligation is unchanged); dropping them turns the record into an older engine's, never into a wrong holder.
31
+ - **Consumers.** server (S-266) — route and redeem from `parks[]` alone: the rows holding a session are the entries with `parkedBy === record.id`; the highest `generation` held by exactly one record routes, a tie is the fork 409, a record with no holder entry for the session answers as today; its design's "a new token's first park is 0" reads "a call's first park in its chain is 0; a new token at a call whose entry the run inherited keeps that generation" (C-R251); the parallel and stream lanes write the same entry shape as the sequential one (one line); `agents[]` routing retires. client-core — mirror the two optional members on the run record's `parks` entry. test — five cells (G1–G5: a fresh park on each lane; the chain — inherited not reached, re-park, drive to a new token, the tail; a lost journal append; a fork admitted; an older record and a malformed member).
32
+ - Pins: `test/backlog1319-park-holder-generation.test.ts` (① lanes and the same-write snapshot, ② inheritance and the chain's tail, ③ the lost append, ④ the fork, ④b both-or-neither on every entry written, ⑤ older records, ⑤b a recorded entry completed by the journal keeps its holder and a contradicting half refuses, ⑥ malformed members; real `runWorkflow` over a runner stub and a checkpoint seat); re-pinned the exact entry shape in `backlog757-parks-persist-order`, `backlog652-park-truth-from-store`, `backlog755-resume-migration`, `backlog642-workflow-park-lane`.
33
+
34
+ ### A proxy that refuses the tunnel is a definite answer: short ladder, a plain status line, a sentence chosen by the proxy's status (#1157; behavior; +1 export) (@server @cli @client-core @test)
35
+
36
+ - **Before (v7.43.x).** An outbound proxy that answered the tunnel request (`CONNECT`) with 403 / 407 reached the engine as a `fetch failed` whose cause carried no code the connect judge knew, so it was read as indeterminate and took the full transport ladder: 11 attempts, 185 s on the production backoff (measured on a fake clock against a fake `fetch`: 11 calls, 185 155 ms), every wait frame saying `connection lost, reconnecting` (n/10), and a terminal failure `[network] fetch failed (…)` that never said a proxy had refused (test 7.103.0 acceptance, `sema -p` 188.8 s with no output).
37
+ - **Now.** `PROXY_TUNNEL_REFUSED` (`"ERR_PROXY_TUNNEL_REFUSED"`, exported at the package root) is a member of the same table as `ECONNREFUSED` / `ENOTFOUND`, read by the same one judge (`readConnectFailure`, one walk of the `cause` / `errors` graph; `classifyConnectFailure` is its verdict). A graph whose proxy-refusal node sits anywhere in it, under code-less wrappers, is a definite connect answer: the short lane (2 retries by default, ≤1 s backoff ceiling; 3 calls, 1 584 ms on the same fake clock) with the wait frames reading `proxy refused the tunnel, retrying briefly` (`phase` stays `reconnecting`, `errClass: "connect_refused"`; no status on the frame) and a terminal failure that begins with one sentence chosen by the status the proxy stated on that node (`statusCode`, an integer 100–599 naming a failure): 407 `proxy authentication required (HTTP 407)`, 403 `proxy forbids this destination (HTTP 403)`, any other `proxy refused the tunnel (HTTP <n>)`, none readable `proxy refused the tunnel`; the cause chain follows after ` — `. The unanimity rule is unchanged: a foreign code anywhere in the graph (a reset above or below the node, an aggregate leg that timed out) keeps the long ladder. `deterministicConnectRetries` at or above `maxRetries` turns the short lane off for it too (long ladder, frame `proxy refused the tunnel, retrying`).
38
+ - **The fetch-throw gate reads the same table.** A `TypeError` the runtime's shell does not name and that carries no `cause` used to be read as "the request was never built" (`invalid_request`, no retry); one whose own node states a definite connect answer (`PROXY_TUNNEL_REFUSED`, and by the same table `ECONNREFUSED` / `ENOTFOUND`) is now transport evidence — the short lane and the proxy sentence apply to it too (a hand-written `fetchImpl` that forwards the dispatcher's stamped error without a wrapper). A causeless `TypeError` with any other code, or none, is unchanged.
39
+ - **The proxy's status is not published as the provider's.** `terminal.detail` (`provider_rejected`), `TaskResult.apiFailure.status`, `BrainError.status` and the retry frame's `errorStatus` stay absent for this failure: they say what the PROVIDER answered, and a 407 from a proxy read there would send a consumer to "change the request and resend" (pinned; one mutation of it reds).
40
+ - **Who is hurt.** A deployment whose proxy refuses the tunnel for a short while and then recovers (a credential rotation, a policy push taking tens of seconds) rode the long ladder to success; it now fails after two retries (~2 s). A consumer matching the terminal text `[network] fetch failed (…)` for this failure sees the sentence first. Nothing else moves: a silent proxy (dial deadline), a reset and every undecoded `OutboundProxyError` keep the transport ladder.
41
+ - **Compensation.** `deterministicConnectRetries` ≥ `maxRetries` restores the long ladder (with the proxy wording on the frames). A host that must branch on 407 / 403 reads the sentence's `(HTTP n)` today; a structural seat for the status (a new `terminal.detail` kind) is not in this version.
42
+ - **Consumers.** server (S-803) — in the dispatcher's non-200 `CONNECT` branch, mint the error with `code: PROXY_TUNNEL_REFUSED` (import it from `@sema-agent/core`) and the proxy's status as a numeric `statusCode` on the same node; dial failures keep wrapping the original errno error with no code of their own (the errno below decides, as today). The node's own cause must carry no code: a stock undici `ProxyAgent` spells this refusal with `UND_ERR_ABORTED` beneath `fetch failed`, and a stamp placed over that coded error is read as indeterminate (long ladder; measured with undici 7.30.0 against a listener answering `CONNECT` with 407). Core has not verified that a custom code on that node leaves undici's connection-phase recovery alone (the dispatcher's own note ties "no code" to that); that check is the server's. cli — nothing to render specially: the failure text and the frame detail carry the words. client-core — `BrainRetryErrClass` unchanged; if a mirror lists the root exports, add `PROXY_TUNNEL_REFUSED` (a string). test — acceptance cell: a fake `fetch` rejecting `TypeError("fetch failed", { cause: { code: "ERR_PROXY_TUNNEL_REFUSED", statusCode: 407 } })` ends in ~2 s with the 407 sentence; the same error without the code ends after 11 attempts.
43
+ - **Known limits.** KL-1157-a (Bun hands a proxy's CONNECT 403 back as a Response, not a throw: read as `auth` / "gateway HTTP 403", no code to carry; no text sniffing). KL-1157-b (the fetch-throw gate reads the connect judge's unanimous verdict as transport evidence: a hand-built causeless `TypeError` stamped with a table code over a mixed aggregate still reads unbuildable, as before; a plain object copying an invalid-URL cause under a proxy stamp reads transport). The MCP / A2A failure table reads an `ERR_*` code as unknown (no `delivered: no` for a refused tunnel) — separate seat, not this version.
44
+ - Pins: `test/backlog1157-proxy-tunnel-refused.test.ts` (36 cells: ladder × {407, 403, 502} on a fake clock with the production backoff, the code-less control and a foreign-coded wrapper on the long ladder, lane-off wording, recovery, three sentences + seven unreadable statuses + chain evidence, a Runner end to end, the provider-status seats absent, a stamped causeless `TypeError` through the fetch-throw gate and the engine, the node at the top / under the shell / mid-chain / sole aggregate leg, three unanimity vetoes, a cyclic and a hostile graph, the exported constant); `test/export-surface.snapshot.json` (+1 name); smoke decision row. Base v7.43.x: 17 of the first 22 cells red (11 attempts, 185 s); the stamped-`TypeError` cells were red before the fetch-throw gate read the table.
45
+
46
+ ### A scope home that is not a real directory is refused with its own code, and a coded store refusal's sentence says no host path (#1368; behavior) (@server @client-core @test)
47
+
48
+ - **Before (v7.43.0 and earlier).** A memory scope home (`<memoryDir>/<label>`) replaced by a symbolic link, a regular file or any other non-directory was refused with a `ControlPlaneCorruptError` that carried no `code` and no structured member — `scope home <path> is a symbolic link — a scope home must be a real directory (fail-closed)` — the class the control plane raises for damaged ledgers, so a host could not tell an unmet layout precondition from damage, and a host that maps coded memory refusals to a conflict answer and everything else to an internal error answered this swap with the latter (a 500, on the server's black-box report) while the same swap one level down (a topic directory replaced by a link out) answered the coded `memory.target_outside_store` (a 409). That code's sentences named host paths (the act's target and where it resolved, the store root and where it now resolves, and on a journal replay the journal's own path) and the error carried no `detail`.
49
+ - **Now.** `memory.scope_home_not_directory` — a plain `Error` with `.code` and `.detail: { path, form }`, `form` one of `"symlink" | "file" | "other"` judged by `lstat` (a link is never followed; a dangling one is a link, not an absent home) — on every face that reaches a home: registration (`registerScope`, an engine mount, the file backend's reads and its commit planning), the layout adoption's owner-home pre-checks, and `ensureScopeHome`'s own creation (a `recursive` mkdir over a link to a directory reports success and the check after it refuses it; a mkdir that fails because a file, FIFO or dangling link stands there re-judges the form and refuses with the code; nothing is registered). One minter in `layout.ts` (`storeFormRefusal`) with three seats — an act refused, a root that moved, a home that is not a directory — and `memory.target_outside_store` is minted by the same function: the code leads the sentence, the sentence names no path, `detail` is `{ path }` (the act's target, or the memory dir's spelling for a moved root). The hand-written sentences in `engine.ts` / `file-backend.ts` are gone. The verdict `landsOutsideStore` answers a boolean (its reason string was where two of the paths came from). **Error class:** the home arm moves from `ControlPlaneCorruptError` (`name: "ControlPlaneCorruptError"`, no `code`) to a plain `Error` (`name: "Error"`) with `code`; `memory.target_outside_store` stays a plain `Error` with `code` and gains `detail`.
50
+ - **Who is hurt.** A host or log script that matched the old sentence text (`scope home … is a symbolic link`, `… resolves to <dir>, outside the store root <root>`, the path inside any of them) or tested `instanceof ControlPlaneCorruptError` for a scope home that is not a directory. Every line built from the sentence loses the path with it: the harvest's `incident.detail` and `warnings` text, the batch planning's per-patch conflict `reason` (`io error: …`).
51
+ - **Compensation.** Branch on `code`; read `detail.path` / `detail.form` for the path and the form. `instanceof ControlPlaneCorruptError` still holds for every real control-plane damage arm; only the home-form arm left it.
52
+ - **Consumers.** server (S-806) — the `targetOutsideStoreOf` family reads one more code (`memory.scope_home_not_directory`), and a flat store and a nested store with a swapped root now answer the same conflict; an operator leg that relays the sentence relays no path (it is `detail.path`). client-core — the memory-code mirror +1. test — a swapped-root black-box in both layouts (entries directly in the home / under a topic directory).
53
+ - **Known limits.** A scope home swapped for a file, FIFO or dangling link in the window between a face's form check and one of the four `mkdirSync(dirname(<target>), { recursive: true })` calls that create an entry's directory (the backend's staging of a flat entry, the engine's missing-file restore, its index write and its projection write) still surfaces the file system's own error (`EEXIST` / `ENOENT`, host path in its text, no code) there; the refusal stands and nothing is written outside, only the code is missing. Not reachable without a concurrent actor swapping the home inside that window (pin ① swaps before the face runs and every face answers the code). A TOPIC directory replaced by a regular file (not a link) answers the same native `EEXIST` on a write under it, on this version and the last. Both are #1378 (one directory-creation seat that answers the code); the commit planning's `conflicts[]` entry still carries the refusal as text only (the code leads the sentence, no `code` member) — #1378 too. Coded memory refusals outside the store-form family that still spell a host path (`config.memory_scope_placement`, `config.memory_project_marker_nested`) and the layout preconditions still reported as control-plane damage are #1379.
54
+ - Pins: `test/backlog1368-scope-home-not-directory.test.ts` — ① both layouts × symlink / file / FIFO on registration, the file backend's read, an engine mount and the commit planning, ② `ensureScopeHome` (link to a directory, a link / file / FIFO / dangling link racing in before the mkdir, a dangling link, the layout adoption's owner-home check, controls), ③ the four `memory.target_outside_store` arms path-free with `detail.path`, ④ one mint site and the closed form set; re-pinned `backlog1094` ① / ⑭a (the sentence names no path; `detail.path` does) and `memory-layout-migration` P2 (a home that is a link / a file answers the code).
55
+
56
+ ### The two undrained families name the cause from one set under one name (#1362; hard-breaking surface) (@server @client-core @test)
57
+
58
+ - **Before (v7.43.x).** `task.user_steer_undrained` carried `detail.cause` (closed `USER_STEER_UNDRAINED_CAUSES`: `leg_end` minted here, `crashed` minted by a serving layer for steers the dead process never confirmed consumed); `task.user_followup_undrained` carried none. A `later` steer rides the follow-up lane, so the same loss was `task.user_followup_undrained` with no cause when the leg ended, and — counted by a serving layer after a crash — a steer-family row with `cause: "crashed"`: the code changed with who reported it.
59
+ - **Now.** One set, one name: `USER_INPUT_UNDRAINED_CAUSES` (`src/core/engine-notice.ts`), the `detail.cause` of BOTH families. `undrainedUserInputNotices` stamps one value for both rows — the leg's own and a parent's copy (`{ followUp, cause: "leg_end", taskId?, sessionId?, runId? }`; the copy `{ followUp, cause: "leg_end", taskId?, sessionId, childSessionId }`). Codes stay two and follow the LANE the input was queued on (steer: `next` / `now`; follow-up: `later`); the `NOTICE_MINTER` member-level exception now covers both codes — their `crashed` arm is the serving layer's, choosing the family by lane, not by verb. No new code; `ENGINE_NOTICE_CODES`, `NOTICE_AUDIENCE` and `NOTICE_MINTER` rows unchanged.
60
+ - **Who is hurt.** See the hard-breaking line (the rename). A consumer that pinned the follow-up row's `detail` exactly sees one more key (`cause`). A mirror of the set downstream renames with it.
61
+ - **Compensation.** Import `USER_INPUT_UNDRAINED_CAUSES` / `UserInputUndrainedCause`; read `cause` on either family the same way.
62
+ - **Consumers.** server (S-1122) — the crash arm picks the code by the lane the lost input was queued on (`later` ⇒ `task.user_followup_undrained` with `cause: "crashed"`), not by the request verb; rename the mirrored set. client-core — rename the mirror table of the set. test — both families' `cause` on a real leg end and on a parent's copy.
63
+ - Pins: `test/backlog1362-undrained-cause-one-vocabulary.test.ts` (⑦u the factory, ⑦ two real lanes on a child leg — a `later` steer stranded by a halt, a `next` steer cut by interrupt —, ⑧ the package root and the export snapshot, ⑨ CLOSED-SETS and the minter's exception sentence); re-pinned `backlog1233-steer-undrained-cause` (② the follow-up row now carries the cause), `backlog1279-child-undrained-to-parent`, `ref-b6-harness-injection-enqueue`.
64
+
65
+ ### `steering.duplicate_input_id` says which facets differ — one judgement for both seats, and no fresh-key advice for the same instruction (#1374) (@server @client-core @test)
66
+
67
+ - **Before (v7.43.x).** Two comparators wrote the same four-facet test twice — the parked queue's (`appendPendingSteer`) over the caller's text, the live verb's over the delivered payload (the framed bytes, with trust and the speaker label folded in) — and both seats answered every non-replay with one sentence, "re-issue this one with a fresh inputId", and no `detail`. A client that re-sent the SAME instruction at another tier (or trust, or attribution) under its key and followed the sentence queued the instruction twice.
68
+ - **Now.** One function, `steerReplayDiffers` (`src/core/steer-replay.ts`), answers which facets a repeat differs in — closed `STEER_REPLAY_FACETS`: `text`, `trusted`, `priority` (absent reads `next`), `actor` (`id`, `hostAsserted`, `issuer`) — in the set's order; empty is the idempotent replay. Both seats judge what the caller SENT (the live seat remembers the caller's record, the one a durable park carries), and the same answer feeds the decision, the refusal's `detail.differs` (by name only, never the values; always present on this code, never empty) and one sentence mint: with `text` listed, "a different steering instruction is already parked under inputId "<id>" / accepted under this inputId (this repeat differs in: …) — re-issue this one with a fresh inputId …"; without, "the same steering instruction is already … ; this repeat differs only in: …, which cannot change after acceptance — sending it again under another inputId would queue it a second time; to confirm the standing receipt, replay it with its original values". The parked face carries it as `CheckpointError.detail.differs`, the live face on the rejected `Error` (`code` + `detail`). One code for every arm. What counts as a replay is unchanged: the live seat's frame and speaker label are a one-to-one function of the caller's text for a given trust, speaker and run (`frameMidTurnUserInput` / `projectHumanInput` / `mintSystemReminder`), so comparing the text in place of the delivered bytes answers the same for every pair; the delivered-payload rule for what books a key (an untrusted whitespace-only steer queues nothing and reserves nothing; a trusted blank one does) is untouched.
69
+ - **Who is hurt.** A host that matched the old sentence text (the code is the contract; it is unchanged). A host that re-sent an accepted instruction at another tier under a fresh key because the sentence told it to — the sentence now says that queues it twice.
70
+ - **Compensation.** Branch on `detail.differs`: includes `"text"` ⇒ another instruction, take a fresh key; otherwise ⇒ the accepted instruction, replay it with its original values (or accept the standing receipt).
71
+ - **Consumers.** server — the 409 body passes `detail.differs` through from both faces (`STEER_REPLAY_FACETS` is exported for its schema). client-core (CC-374) — two arms by whether `differs` includes `"text"`. test — each facet alone on both seats, and the tier-only sentence.
72
+ - Pins: `test/backlog1374-steer-replay-one-difference.test.ts` (① each facet alone × the parked seat on the in-memory and the file store × the live seat on a real run, ② several facets in the set's order, ③ the same value — absent tier × `next`, a fresh actor object, a trusted one — still the idempotent replay, ③b the booking rule for blank steers, ④ the tier-only sentence, ⑤ both seats answer one `differs`, ⑥ one comparison and one sentence under `src/`); re-pinned `backlog1361-steer-priority-one-reading` (the live cell reads the one function), `steer-input-id` (⑤ names the facets), `steering-pending`.
73
+
1
74
  ## 7.43.0 — 2026-10-06
2
75
 
3
76
  **Hard-breaking changes:** one — #1318. **#1318 — What breaks:** the parameter spelling of a Skill rule — `Skill(skill:<name>)`, and `Skill(args:…)` / `Skill(skill:*)` with it — a working but non-reference spelling in a caller policy (`createPermissionRulePolicy`), is refused at construction (`config.invalid_permission_rules`, one `issues[]` entry per rule with `code: "invalid.command_rule"`, the sentence naming `Skill(<name>)` and `Skill(<name>:*)`; under `onInvalidRule: "skip"` it is reported through `onIssue` and dropped, so a deny written that way stops firing until it is respelled); the reference's own spelling `Skill(<name>)` / `Skill(<prefix>:*)`, which 7.42.x refused (`invalid.param_split` in a policy, `unsupported.tool` in the persisted grammar), is now the one spelling, read by both compilers. **Who is hurt:** a host that wrote the parameter spelling — the serving layer that encodes a `permissions.ask` / `permissions.deny` entry of the Skill tool into policy rules, a hand-written policy; a probe that pinned `Skill(<name>)` as refused; a consumer that switches exhaustively over `PersistedRuleMatch`. An organization rule file with a `Skill(…)` row still refuses the WHOLE snapshot (the org grammar is the shell's alone — unchanged). **Compensation:** respell `Skill(skill:deploy)` as `Skill(deploy)`, and a wildcard on the skill (`Skill(skill:deploy*)`) as `Skill(deploy:*)` (a name PREFIX — no word boundary).
@@ -361,7 +361,8 @@ export interface SubagentSteerHandle {
361
361
  * contract, value domain and typed refusals are that verb's; absent ⇒ byte-identical to every
362
362
  * pre-existing call). ⚠️ Same deliberate arm gap as the workflow twin: each call mints a FRESH
363
363
  * correlation marker into the framing, so a retry under the same id is a same-id-DIFFERENT-
364
- * instruction call and refuses typed `steering.duplicate_input_id` — the key buys AT-MOST-ONCE
364
+ * instruction call and refuses typed `steering.duplicate_input_id` (`detail.differs` always names `text`
365
+ * here) — the key buys AT-MOST-ONCE
365
366
  * (loud refusal), never silent idempotent replay. */
366
367
  steer: (content: string, opts?: {
367
368
  inputId?: string;
@@ -147,6 +147,14 @@ export declare function extractErrorCode(errorMessage: string | undefined): Brai
147
147
  * human text. Pairs with {@link extractErrorCode} so a caller can lift the code and clean the message
148
148
  * without re-hardcoding the code list (the Runner does both when assembling `TaskResult`). */
149
149
  export declare function stripErrorCodePrefix(errorMessage: string): string;
150
+ /**
151
+ * The code a deployment's outbound dispatcher stamps on the error of a proxy that answered the tunnel request
152
+ * (`CONNECT`) with anything but 200, with the HTTP status the proxy gave in `statusCode` on the same node. Not a
153
+ * runtime code — undici spells that refusal with prose and no stable code — so it is defined here, once, for the
154
+ * deployment to mint and for {@link DETERMINISTIC_CONNECT_CODES} to read. The proxy ANSWERED: re-sending the same
155
+ * request through the same proxy only changes that answer if someone changes the proxy's rules.
156
+ */
157
+ export declare const PROXY_TUNNEL_REFUSED = "ERR_PROXY_TUNNEL_REFUSED";
150
158
  /**
151
159
  * Whether a transport-level connect failure is DETERMINISTIC (see {@link DETERMINISTIC_CONNECT_CODES})
152
160
  * or gives no such verdict. Classification is by `code` along the error graph — never by message text,
@@ -165,7 +173,18 @@ export declare function stripErrorCodePrefix(errorMessage: string): string;
165
173
  * pre-existing behavior.
166
174
  */
167
175
  export type ConnectFailureClass = "deterministic" | "indeterminate";
176
+ /** What one walk of a transport error's graph found: the verdict, and — only with a `deterministic` one — the proxy
177
+ * refusal in it, if any (`status` absent when the node states none readable). One walk answers both, so the verdict
178
+ * and the sentence it licenses cannot disagree about which node spoke. */
179
+ export interface ConnectFailureReading {
180
+ verdict: ConnectFailureClass;
181
+ proxyRefusal?: {
182
+ status: number | undefined;
183
+ };
184
+ }
185
+ export declare function readConnectFailure(e: unknown): ConnectFailureReading;
168
186
  export declare function classifyConnectFailure(e: unknown): ConnectFailureClass;
187
+ export declare function proxyTunnelRefusedSentence(status: number | undefined): string;
169
188
  /** What a throw out of `fetch` was about: the NETWORK (retryable — the historic reading of every throw
170
189
  * here), or the REQUEST ITSELF, which is deterministic: identical arguments reproduce it every time. */
171
190
  export type FetchThrowClass = "transport" | "request_unbuildable";
@@ -174,8 +193,10 @@ export type FetchThrowClass = "transport" | "request_unbuildable";
174
193
  * throws a `TypeError` with no `code` and no `cause`, so the connect classifier answered indeterminate and
175
194
  * the call spent its whole backoff budget on a request the runtime never built).
176
195
  *
177
- * ONE question — is there TRANSPORT EVIDENCE? — in its two forms: the runtime's own shell sentence, or a
178
- * `cause` that is a transport fact rather than a parse verdict. A `TypeError` with neither was never built.
196
+ * ONE question — is there TRANSPORT EVIDENCE? — in its three forms: the runtime's own shell sentence, a
197
+ * `cause` that is a transport fact rather than a parse verdict, or a graph the connect judge reads as a
198
+ * definite answer ({@link classifyConnectFailure}: the same table, the same unanimity). A `TypeError` with none
199
+ * was never built.
179
200
  * Measured over ten real shapes; non-`TypeError` stays on the transport arm and an unreadable value answers
180
201
  * `transport`, the pre-existing behavior. Stated cost: KL-933a. */
181
202
  export declare function classifyFetchThrow(e: unknown): FetchThrowClass;
@@ -138,8 +138,13 @@ function hasStaleConnectionCode(e) {
138
138
  }
139
139
  return false;
140
140
  }
141
- const DETERMINISTIC_CONNECT_CODES = new Set(["ECONNREFUSED", "ENOTFOUND"]);
141
+ export const PROXY_TUNNEL_REFUSED = "ERR_PROXY_TUNNEL_REFUSED";
142
+ const DETERMINISTIC_CONNECT_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", PROXY_TUNNEL_REFUSED]);
142
143
  const CONNECT_GRAPH_NODE_BUDGET = 16;
144
+ function proxyStatusOf(node) {
145
+ const status = readMember(node, "statusCode");
146
+ return typeof status === "number" && Number.isInteger(status) && status < 600 && namesTheFailure(status) ? status : undefined;
147
+ }
143
148
  function walkConnectFailure(node, budget) {
144
149
  if (budget.left <= 0)
145
150
  return "indeterminate";
@@ -151,6 +156,8 @@ function walkConnectFailure(node, budget) {
151
156
  if (typeof code === "string") {
152
157
  if (!DETERMINISTIC_CONNECT_CODES.has(code))
153
158
  return "indeterminate";
159
+ if (code === PROXY_TUNNEL_REFUSED && budget.proxy === undefined)
160
+ budget.proxy = { status: proxyStatusOf(node) };
154
161
  verdict = "deterministic";
155
162
  }
156
163
  if (!isErrorLike(node))
@@ -176,8 +183,23 @@ function walkConnectFailure(node, budget) {
176
183
  }
177
184
  return verdict;
178
185
  }
186
+ export function readConnectFailure(e) {
187
+ const walk = { left: CONNECT_GRAPH_NODE_BUDGET };
188
+ if (walkConnectFailure(e, walk) !== "deterministic")
189
+ return { verdict: "indeterminate" };
190
+ return walk.proxy === undefined ? { verdict: "deterministic" } : { verdict: "deterministic", proxyRefusal: walk.proxy };
191
+ }
179
192
  export function classifyConnectFailure(e) {
180
- return walkConnectFailure(e, { left: CONNECT_GRAPH_NODE_BUDGET }) === "deterministic" ? "deterministic" : "indeterminate";
193
+ return readConnectFailure(e).verdict;
194
+ }
195
+ const PROXY_TUNNEL_REFUSED_SENTENCES = new Map([
196
+ [407, "proxy authentication required (HTTP 407)"],
197
+ [403, "proxy forbids this destination (HTTP 403)"],
198
+ ]);
199
+ export function proxyTunnelRefusedSentence(status) {
200
+ if (status === undefined)
201
+ return "proxy refused the tunnel";
202
+ return PROXY_TUNNEL_REFUSED_SENTENCES.get(status) ?? `proxy refused the tunnel (HTTP ${status})`;
181
203
  }
182
204
  const FETCH_TRANSPORT_SHELLS = new Set(["fetch failed", "terminated"]);
183
205
  const REQUEST_ARGUMENT_CODES = new Set(["ERR_INVALID_URL"]);
@@ -187,6 +209,8 @@ export function classifyFetchThrow(e) {
187
209
  const message = readMember(e, "message");
188
210
  if (typeof message === "string" && FETCH_TRANSPORT_SHELLS.has(message))
189
211
  return "transport";
212
+ if (classifyConnectFailure(e) === "deterministic")
213
+ return "transport";
190
214
  const cause = readMember(e, "cause");
191
215
  if (cause === undefined || cause === null)
192
216
  return "request_unbuildable";
@@ -27,7 +27,8 @@ export interface StreamEngineConfig extends BrainTimeoutConfig {
27
27
  retryDelayMs?: number;
28
28
  /**
29
29
  * Retries allowed for a DETERMINISTIC connect failure — one whose error codes say the target itself
30
- * is not there (`ECONNREFUSED`, or a name with no address; see `errors.ts` `classifyConnectFailure`).
30
+ * is not there (`ECONNREFUSED`, or a name with no address) or that the proxy on the way refused the tunnel
31
+ * (`PROXY_TUNNEL_REFUSED`); see `errors.ts` `readConnectFailure`.
31
32
  * Absent ⇒ {@link DEFAULT_DETERMINISTIC_CONNECT_RETRIES}.
32
33
  *
33
34
  * These retries exist for one case only: a peer that is mid-restart and about to accept. Waiting
@@ -1,7 +1,7 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
2
  import { STREAM_TORN_WARNING } from "./stream-shared.js";
3
3
  import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } from "./context-overflow.js";
4
- import { abortLikeName, BrainError, brainErrorCodeOf, classifyConnectFailure, classifyFetchThrow, classifyHttp, describeNetworkError, isProviderEchoShape, namesTheFailure, readProviderErrorCode, readProviderRequestId, refusedTheRequest } from "./errors.js";
4
+ import { abortLikeName, BrainError, brainErrorCodeOf, classifyFetchThrow, classifyHttp, describeNetworkError, isProviderEchoShape, namesTheFailure, proxyTunnelRefusedSentence, readConnectFailure, readProviderErrorCode, readProviderRequestId, refusedTheRequest } from "./errors.js";
5
5
  import { stripUrlCredentials } from "./route-adjudicator.js";
6
6
  import { classifyInputTooLong } from "./input-too-long.js";
7
7
  import { FAST_MAX_BACKOFF_MS, providerWaitHint, retryBackoffMs } from "./retry.js";
@@ -531,7 +531,8 @@ export function runStreamingBrain(args) {
531
531
  const retryableByStatus = netErr !== undefined ||
532
532
  (r !== undefined && (r.status >= 500 || r.status === 429 || r.status === 408 || r.status === 409));
533
533
  const retryable = shouldRetryHeaderVerdict(r) ?? retryableByStatus;
534
- const deterministicConnect = netErr !== undefined && classifyConnectFailure(netErr) === "deterministic";
534
+ const connectReading = netErr !== undefined ? readConnectFailure(netErr) : undefined;
535
+ const deterministicConnect = connectReading?.verdict === "deterministic";
535
536
  const fastLane = deterministicConnect && deterministicLaneActive;
536
537
  const laneMaxRetries = fastLane ? deterministicConnectBudget : maxRetries;
537
538
  const errClass = netErr !== undefined
@@ -552,11 +553,8 @@ export function runStreamingBrain(args) {
552
553
  ? ` (the requested wait was longer than the cap; honoring ${Math.ceil(waitHint.ms / 1000)}s instead of ${Math.ceil(waitHint.rawMs / 1000)}s)`
553
554
  : "";
554
555
  const statusPhase = r?.status === 429 ? "rate_limited" : netErr !== undefined ? "reconnecting" : "retrying";
555
- const reconnectDetail = errClass !== "connect_refused"
556
- ? "connection lost, reconnecting"
557
- : fastLane
558
- ? "no endpoint answering, retrying briefly"
559
- : "no endpoint answering, retrying";
556
+ const refusedSubject = connectReading?.proxyRefusal !== undefined ? "proxy refused the tunnel" : "no endpoint answering";
557
+ const reconnectDetail = errClass !== "connect_refused" ? "connection lost, reconnecting" : fastLane ? `${refusedSubject}, retrying briefly` : `${refusedSubject}, retrying`;
560
558
  emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect", errClass, nextDelayMs: delayMs });
561
559
  announcedRetry = true;
562
560
  discardResponseBody(r);
@@ -580,8 +578,11 @@ export function runStreamingBrain(args) {
580
578
  attempt++;
581
579
  continue;
582
580
  }
583
- if (netErr)
584
- throw new BrainError("network", describeNetworkError(netErr), undefined, { apiError: true });
581
+ if (netErr) {
582
+ const chain = describeNetworkError(netErr);
583
+ const proxyRefusal = connectReading?.proxyRefusal;
584
+ throw new BrainError("network", proxyRefusal === undefined ? chain : `${proxyTunnelRefusedSentence(proxyRefusal.status)} — ${chain}`, undefined, { apiError: true });
585
+ }
585
586
  const detail = overflowBody ?? (r ? (await readErrorBody(r, signal)).text : "");
586
587
  const status = r?.status ?? 0;
587
588
  const itl = classifyInputTooLong(status, detail);
@@ -1,7 +1,8 @@
1
1
  import type { GateOutcome } from "./gate-outcome.js";
2
2
  import { type ExecutionOutcomeRecordWord } from "./checkpoint-execution-record.js";
3
3
  import type { CheckpointPause } from "./pause-registry.js";
4
- import { type SystemInjectionPriority } from "./task-notification.js";
4
+ import type { SystemInjectionPriority } from "./task-notification.js";
5
+ import { type SteerReplayFacet } from "./steer-replay.js";
5
6
  import { type QuestionAnswer } from "./ask-question.js";
6
7
  import type { ReadEntry } from "../tools/fs/safety.js";
7
8
  import type { RepairBundle } from "../agents/repair-loop.js";
@@ -2130,10 +2131,12 @@ export declare class CheckpointError extends Error {
2130
2131
  * the caller delivers or removes a parked steer first. Nothing is written on this path. */
2131
2132
  | "steering.queue_full"
2132
2133
  /** design/171 §6.3 (falsification review adoption): the append reused an `inputId` already parked on
2133
- * this checkpoint, but with DIFFERENT content. Re-appending an IDENTICAL payload stays the
2134
- * idempotent no-op a retry needs; a differing one is refused, because swallowing it would lose the
2135
- * second operator's instruction — the exact silent loss the queue replaced. Retry with a fresh id.
2136
- * Nothing is written on this path. */
2134
+ * this checkpoint, but with DIFFERENT content. Re-appending an IDENTICAL steer stays the idempotent
2135
+ * no-op a retry needs; a differing one is refused, because swallowing it would lose the second
2136
+ * operator's instruction — the exact silent loss the queue replaced. `detail.differs` names the facets
2137
+ * that differ: with `text` among them it is another instruction (retry it under a fresh id); without,
2138
+ * it is the parked one re-sent at another tier / trust / attribution (a fresh id would park it twice —
2139
+ * replay it with its original values). Nothing is written on this path. */
2137
2140
  | "steering.duplicate_input_id"
2138
2141
  /** design/373 §4.3 (D2): the deployment's `userPromptSubmit` hook refused a wake resume's
2139
2142
  * `message` at the entrance screen (block verdict, or a fail-closed timeout/crash — the
@@ -2217,8 +2220,8 @@ export declare class CheckpointError extends Error {
2217
2220
  * computable from a self-reported closure, so the edited resume is refused typed pre-CAS (the
2218
2221
  * checkpoint stays `pending`, still decidable); an UNEDITED approval is untouched. */
2219
2222
  | "resume.constraint_unprojectable";
2220
- /** Structured discrimination a caller can branch on where the code alone is ambiguous. Two
2221
- * members. `field` names WHICH part of the decision payload was rejected on a
2223
+ /** Structured discrimination a caller can branch on where the code alone is ambiguous. `field`
2224
+ * names WHICH part of the decision payload was rejected on a
2222
2225
  * `checkpoint.invalid_outcome` — `"boundCallId"` (the action you decided on has been replaced:
2223
2226
  * re-fetch the pending list), `"boundInputHash"` (the input you reviewed has changed under the same
2224
2227
  * action: re-review), `"boundGrantDigest"` (a parked `Skill` approval does not cover the skill the decision selects — another skill, or the card's with other lists: deny, re-activate), or `"answer"` (the content-ask answer is missing, or was attached to a
@@ -2248,6 +2251,10 @@ export declare class CheckpointError extends Error {
2248
2251
  * (`code` remains the only REQUIRED discriminant), which is exactly why the obligation to SPELL it
2249
2252
  * is a gate rather than a type: see the closed set's own doc. */
2250
2253
  reason?: CheckpointErrorReason;
2254
+ /** On `steering.duplicate_input_id` — the facets the repeat differs from the parked steer in (closed
2255
+ * {@link SteerReplayFacet}, in that set's order; always present on that code, never empty). Absent on
2256
+ * every other code. The live verb's refusal carries the same value. */
2257
+ differs?: readonly SteerReplayFacet[];
2251
2258
  } | undefined;
2252
2259
  constructor(code: "checkpoint.already_exists" | "checkpoint.already_resolved" | "checkpoint.not_found"
2253
2260
  /** `runner.resume` was handed an {@link ResumeOutcome} whose `gate` arm does not match the
@@ -2322,10 +2329,12 @@ export declare class CheckpointError extends Error {
2322
2329
  * the caller delivers or removes a parked steer first. Nothing is written on this path. */
2323
2330
  | "steering.queue_full"
2324
2331
  /** design/171 §6.3 (falsification review adoption): the append reused an `inputId` already parked on
2325
- * this checkpoint, but with DIFFERENT content. Re-appending an IDENTICAL payload stays the
2326
- * idempotent no-op a retry needs; a differing one is refused, because swallowing it would lose the
2327
- * second operator's instruction — the exact silent loss the queue replaced. Retry with a fresh id.
2328
- * Nothing is written on this path. */
2332
+ * this checkpoint, but with DIFFERENT content. Re-appending an IDENTICAL steer stays the idempotent
2333
+ * no-op a retry needs; a differing one is refused, because swallowing it would lose the second
2334
+ * operator's instruction — the exact silent loss the queue replaced. `detail.differs` names the facets
2335
+ * that differ: with `text` among them it is another instruction (retry it under a fresh id); without,
2336
+ * it is the parked one re-sent at another tier / trust / attribution (a fresh id would park it twice —
2337
+ * replay it with its original values). Nothing is written on this path. */
2329
2338
  | "steering.duplicate_input_id"
2330
2339
  /** design/373 §4.3 (D2): the deployment's `userPromptSubmit` hook refused a wake resume's
2331
2340
  * `message` at the entrance screen (block verdict, or a fail-closed timeout/crash — the
@@ -2409,8 +2418,8 @@ export declare class CheckpointError extends Error {
2409
2418
  * computable from a self-reported closure, so the edited resume is refused typed pre-CAS (the
2410
2419
  * checkpoint stays `pending`, still decidable); an UNEDITED approval is untouched. */
2411
2420
  | "resume.constraint_unprojectable", message: string,
2412
- /** Structured discrimination a caller can branch on where the code alone is ambiguous. Two
2413
- * members. `field` names WHICH part of the decision payload was rejected on a
2421
+ /** Structured discrimination a caller can branch on where the code alone is ambiguous. `field`
2422
+ * names WHICH part of the decision payload was rejected on a
2414
2423
  * `checkpoint.invalid_outcome` — `"boundCallId"` (the action you decided on has been replaced:
2415
2424
  * re-fetch the pending list), `"boundInputHash"` (the input you reviewed has changed under the same
2416
2425
  * action: re-review), `"boundGrantDigest"` (a parked `Skill` approval does not cover the skill the decision selects — another skill, or the card's with other lists: deny, re-activate), or `"answer"` (the content-ask answer is missing, or was attached to a
@@ -2440,6 +2449,10 @@ export declare class CheckpointError extends Error {
2440
2449
  * (`code` remains the only REQUIRED discriminant), which is exactly why the obligation to SPELL it
2441
2450
  * is a gate rather than a type: see the closed set's own doc. */
2442
2451
  reason?: CheckpointErrorReason;
2452
+ /** On `steering.duplicate_input_id` — the facets the repeat differs from the parked steer in (closed
2453
+ * {@link SteerReplayFacet}, in that set's order; always present on that code, never empty). Absent on
2454
+ * every other code. The live verb's refusal carries the same value. */
2455
+ differs?: readonly SteerReplayFacet[];
2443
2456
  } | undefined);
2444
2457
  }
2445
2458
  /**
@@ -8,7 +8,7 @@ import { readBoundBinding } from "./skill-grant-binding.js";
8
8
  import { checkpointExecutionRecorded, executionRecordDisposition, ExecutionOutcomeConflictError, } from "./checkpoint-execution-record.js";
9
9
  import { renderUntrustedCommandText, stripFormatCharacters } from "./permission-rule-model.js";
10
10
  import { redactSecrets } from "./untrusted-egress.js";
11
- import { steerPriorityOf } from "./task-notification.js";
11
+ import { steerReplayRefusal } from "./steer-replay.js";
12
12
  import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
13
13
  import { isPermissionMode } from "./ask-origin.js";
14
14
  import { wireOf } from "./tool-name-readings.js";
@@ -548,22 +548,13 @@ export function readPendingSteerQueue(state) {
548
548
  }
549
549
  return [...(state.pendingSteerQueue ?? [])].sort((a, b) => a.seq - b.seq);
550
550
  }
551
- function samePendingSteerPayload(a, b) {
552
- return (a.text === b.text &&
553
- a.trusted === b.trusted &&
554
- steerPriorityOf(a) === steerPriorityOf(b) &&
555
- a.actor?.id === b.actor?.id &&
556
- a.actor?.hostAsserted === b.actor?.hostAsserted &&
557
- a.actor?.issuer === b.actor?.issuer);
558
- }
559
551
  export function appendPendingSteer(state, entry) {
560
552
  const current = readPendingSteerQueue(state);
561
553
  const collision = current.find((e) => e.inputId === entry.inputId);
562
554
  if (collision !== undefined) {
563
- if (!samePendingSteerPayload(collision, entry)) {
564
- throw new CheckpointError("steering.duplicate_input_id", `a different steering instruction is already parked under inputId "${entry.inputId}" — ` +
565
- `re-issue this one with a fresh inputId (an identical payload would have been an idempotent retry)`);
566
- }
555
+ const refusal = steerReplayRefusal(collision, entry, { seat: "parked", inputId: entry.inputId });
556
+ if (refusal !== undefined)
557
+ throw new CheckpointError("steering.duplicate_input_id", refusal.message, refusal.detail);
567
558
  return current;
568
559
  }
569
560
  const nextSeq = current.reduce((max, e) => Math.max(max, e.seq), 0) + 1;
@@ -394,16 +394,17 @@ export interface EngineNotice {
394
394
  * frame's two count keys). They are NOT redelivered (a steer aimed at a finished run must not
395
395
  * fire at the next one — unlike ENGINE notes, which pend per session); the loud half of the
396
396
  * #257 contract's "accepted = enqueued, not consumed" sentence;
397
- * `detail: { steer, cause, taskId?, sessionId?, runId? }` / `{ followUp, taskId?, sessionId?, runId? }`
398
- * (`cause` on the steer row only, closed `USER_STEER_UNDRAINED_CAUSES`: `"leg_end"` — this engine's arm, the
397
+ * `detail: { steer, cause, taskId?, sessionId?, runId? }` / `{ followUp, cause, taskId?, sessionId?, runId? }`
398
+ * (`cause` on both rows, closed `USER_INPUT_UNDRAINED_CAUSES`: `"leg_end"` — this engine's arm, the
399
399
  * leg ended with it still owed; `"crashed"` — minted by the SERVING LAYER, not this engine, before the
400
- * session's next run, for steers it had accepted whose consumption was not confirmed before the process died: their queue was the
401
- * serving layer's, so only it can count them. One code for both, so a consumer de-duplicating on `code` keeps
400
+ * session's next run, for inputs it had accepted whose consumption was not confirmed before the process died: their queue was the
401
+ * serving layer's, so only it can count them, and it picks the family by the LANE the input was queued on (a `later`
402
+ * steer rides the follow-up lane). One code per family for both arms, so a consumer de-duplicating on `code` keeps
402
403
  * one row; `sessionId` = #433's routing half for these two `"user"`-audience rows; `runId` = #499's
403
404
  * per-run identity, the key this account is actually about — see
404
405
  * {@link undrainedUserInputNotices}; all omitted when the caller has none, never fabricated). A delegated child
405
406
  * leg is announced on its PARENT's session as well — the direct parent only — as a second row per family,
406
- * `detail: { steer | followUp, cause?, taskId?, sessionId: <the parent's>, childSessionId }` (no `runId`: nothing
407
+ * `detail: { steer | followUp, cause, taskId?, sessionId: <the parent's>, childSessionId }` (no `runId`: nothing
407
408
  * carries the parent's), message tail `(a child leg, <childSessionId>)`; `childSessionId` present = a child reported it.
408
409
  * Per-run, at most once per family (the terminal sweep is a single site) — which is why
409
410
  * `taskId`/`sessionId` cannot key it: both answer the same value for two runs of one session.
@@ -959,16 +960,17 @@ export declare function settleOwedUserInput(account: OwedUserInputAccount, rowRe
959
960
  parent?: ParentLegIds | undefined;
960
961
  }, onNotice: ((notice: EngineNotice) => void) | undefined): void;
961
962
  /**
962
- * Why a person's steer is reported as not consumed — `task.user_steer_undrained`'s `detail.cause`. Closed:
963
- * · `leg_end` — the leg ended with the steer still owed (this engine mints it, at the leg's settlement);
964
- * · `crashed` — the engine process ended before the leg settled, and a steer the serving layer had accepted for it was
965
- * not confirmed consumed before the process died (a live leg gives the serving layer no evidence that a steer reached
966
- * the model, so such a steer MAY have been consumed). Only the serving layer can count these (the queue was its own), so the serving layer
967
- * mints this arm, before the next run of the session starts — the one member-level exception to
968
- * {@link import("./governance-codes.js").NOTICE_MINTER}'s row for the code, which stays `"engine"`.
963
+ * Why a person's input is reported as not consumed — the `detail.cause` of BOTH undrained families
964
+ * (`task.user_steer_undrained` / `task.user_followup_undrained`; the family is the lane the input was queued on). Closed:
965
+ * · `leg_end` — the leg ended with the input still owed (this engine mints it, at the leg's settlement);
966
+ * · `crashed` — the engine process ended before the leg settled, and an input the serving layer had accepted for it was
967
+ * not confirmed consumed before the process died (a live leg gives the serving layer no evidence that an input reached
968
+ * the model, so such an input MAY have been consumed). Only the serving layer can count these (the queue was its own), so the serving layer
969
+ * mints this arm, choosing the family by the lane the input was queued on, before the next run of the session starts —
970
+ * the one member-level exception to {@link import("./governance-codes.js").NOTICE_MINTER}'s rows for the two codes, which stay `"engine"`.
969
971
  */
970
- export declare const USER_STEER_UNDRAINED_CAUSES: readonly ["leg_end", "crashed"];
971
- export type UserSteerUndrainedCause = (typeof USER_STEER_UNDRAINED_CAUSES)[number];
972
+ export declare const USER_INPUT_UNDRAINED_CAUSES: readonly ["leg_end", "crashed"];
973
+ export type UserInputUndrainedCause = (typeof USER_INPUT_UNDRAINED_CAUSES)[number];
972
974
  /** Where a child leg's account is ALSO announced: its spawner's session, and that run's task id when the spawner carried one
973
975
  * (it never carries the run id, and the row never invents one). */
974
976
  interface ParentLegIds {
@@ -51,7 +51,7 @@ export function settleOwedUserInput(account, rowReturned, ids, onNotice) {
51
51
  }
52
52
  }
53
53
  const SETTLED_OWED_ACCOUNTS = new WeakSet();
54
- export const USER_STEER_UNDRAINED_CAUSES = Object.freeze(["leg_end", "crashed"]);
54
+ export const USER_INPUT_UNDRAINED_CAUSES = Object.freeze(["leg_end", "crashed"]);
55
55
  export function parentLegIds(internals) {
56
56
  const parentSessionId = internals?.parentSessionId;
57
57
  if (typeof parentSessionId !== "string" || parentSessionId === "")
@@ -70,16 +70,17 @@ export function undrainedUserInputNotices(counts, taskId, sessionId, runId, pare
70
70
  const copyTail = ` (a child leg, ${sessionId})`;
71
71
  const own = [];
72
72
  const copies = [];
73
+ const cause = "leg_end";
73
74
  if (counts.steer > 0) {
74
75
  const message = `${counts.steer} user steer(s) ${tail}`;
75
- const count = { steer: counts.steer, cause: "leg_end" };
76
+ const count = { steer: counts.steer, cause };
76
77
  own.push({ code: "task.user_steer_undrained", message, detail: { ...count, ...tid, ...sid, ...rid } });
77
78
  if (toParent)
78
79
  copies.push({ code: "task.user_steer_undrained", message: `${message}${copyTail}`, detail: { ...count, ...ptid, sessionId: parentSession, childSessionId: sessionId } });
79
80
  }
80
81
  if (counts.followUp > 0) {
81
82
  const message = `${counts.followUp} user follow-up(s) ${tail}`;
82
- const count = { followUp: counts.followUp };
83
+ const count = { followUp: counts.followUp, cause };
83
84
  own.push({ code: "task.user_followup_undrained", message, detail: { ...count, ...tid, ...sid, ...rid } });
84
85
  if (toParent)
85
86
  copies.push({ code: "task.user_followup_undrained", message: `${message}${copyTail}`, detail: { ...count, ...ptid, sessionId: parentSession, childSessionId: sessionId } });
@@ -259,9 +259,10 @@ export declare function noticeAudienceOf(code: string): NoticeAudience;
259
259
  * {@link NOTICE_AUDIENCE} like every other row, and NEVER minted by this package (the catalog gate reds a mint site for
260
260
  * one in src/). One code, one minter: a code two processes mint would be two facts under one spelling.
261
261
  *
262
- * Member-level exception, stated where a consumer reads the minter: `task.user_steer_undrained` is an engine row, and
263
- * its `detail.cause: "crashed"` arm is minted by the server — the engine's process is gone when that loss is counted
264
- * (see `USER_STEER_UNDRAINED_CAUSES`). The code keeps one row here and one spelling for both arms.
262
+ * Member-level exception, stated where a consumer reads the minter: `task.user_steer_undrained` and
263
+ * `task.user_followup_undrained` are engine rows, and their `detail.cause: "crashed"` arm is minted by the server — the
264
+ * engine's process is gone when that loss is counted (see `USER_INPUT_UNDRAINED_CAUSES`); the server picks the code by the
265
+ * lane the input was queued on, as this engine does. Each code keeps one row here and one spelling for both arms.
265
266
  */
266
267
  export type NoticeMinter = "engine" | "server";
267
268
  declare const NOTICE_MINTER_TABLE: {
@@ -84,6 +84,7 @@ export const NON_GOVERNANCE_MEMORY_CODES = new Set([
84
84
  "memory.capture_optout_unavailable",
85
85
  "memory.capture_optout_sweep_failed",
86
86
  "memory.target_outside_store",
87
+ "memory.scope_home_not_directory",
87
88
  ]);
88
89
  export function governanceRetryClass(code) {
89
90
  if (Object.prototype.hasOwnProperty.call(GOVERNANCE_CODES, code)) {
@@ -1789,7 +1789,7 @@ export declare class MemoryEngine {
1789
1789
  */
1790
1790
  private gateDerivedIndex;
1791
1791
  /** The file backend's verdict at an act the engine itself performs on a store path ({@link
1792
- * storeTargetEscape}): throws `memory.target_outside_store` when `path` would land outside `root`;
1792
+ * landsOutsideStore}): throws `memory.target_outside_store` when `path` would land outside `root`;
1793
1793
  * each caller's existing failure lane reports it. `root` is the mount's canonical root where a
1794
1794
  * handle exists, so a root re-pointed after the mount is judged against the one it mounted. */
1795
1795
  private refuseOutsideStore;
@@ -1798,7 +1798,7 @@ export declare class MemoryEngine {
1798
1798
  * is never a supported layout (the harvest scan refuses symlinked entry files outright), and this
1799
1799
  * site already reports write failures by throwing, so ELOOP joins the failures it already had. A
1800
1800
  * target whose directory resolves outside `root` (a directory on the path is a link) is refused
1801
- * the same way, before anything is read or written ({@link storeTargetEscape}). */
1801
+ * the same way, before anything is read or written ({@link landsOutsideStore}). */
1802
1802
  private writeIfChanged;
1803
1803
  /** Persist the canonical projection back to the session file (id minting / frontmatter completion).
1804
1804
  * C-F6 (S2-0): called ONLY after the backend transaction committed — never ahead of the journal