@sema-agent/core 7.9.1 → 7.10.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 (61) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/agents/child-model-seat.d.ts +81 -0
  3. package/dist/agents/child-model-seat.js +46 -0
  4. package/dist/agents/subagent.d.ts +5 -3
  5. package/dist/agents/subagent.js +20 -14
  6. package/dist/core/ask-unresolvable-notice.d.ts +52 -0
  7. package/dist/core/ask-unresolvable-notice.js +25 -0
  8. package/dist/core/auto-mode.d.ts +62 -3
  9. package/dist/core/auto-mode.js +31 -0
  10. package/dist/core/checkpoint-store.d.ts +14 -0
  11. package/dist/core/checkpoint-store.js +2 -1
  12. package/dist/core/engine-notice.d.ts +28 -7
  13. package/dist/core/gate-lanes.js +15 -0
  14. package/dist/core/governance-codes.d.ts +1 -1
  15. package/dist/core/governance-codes.js +4 -0
  16. package/dist/core/hooks.d.ts +24 -1
  17. package/dist/core/hooks.js +2 -0
  18. package/dist/core/permission-rule-model.d.ts +51 -16
  19. package/dist/core/permission-rule-model.js +55 -21
  20. package/dist/core/permission-rules.d.ts +6 -4
  21. package/dist/core/permission-rules.js +14 -14
  22. package/dist/core/roles.d.ts +8 -0
  23. package/dist/core/runner/contracts.d.ts +29 -4
  24. package/dist/core/runner/denial-limit-arms.d.ts +14 -3
  25. package/dist/core/runner/denial-limit-arms.js +15 -5
  26. package/dist/core/runner/permission-rule-lanes.d.ts +7 -1
  27. package/dist/core/runner/permission-rule-lanes.js +9 -3
  28. package/dist/core/runner/prepare-caps-and-workflow.d.ts +1 -1
  29. package/dist/core/runner/prepare-caps-and-workflow.js +14 -4
  30. package/dist/core/runner/prepare-gate-stations.d.ts +3 -2
  31. package/dist/core/runner/prepare-gate-stations.js +3 -0
  32. package/dist/core/runner/prepare-policy-chain.js +7 -6
  33. package/dist/core/runner/prepare-wiring-manifest.d.ts +1 -1
  34. package/dist/core/runner/prepare-wiring-manifest.js +8 -1
  35. package/dist/core/runner/runtask.d.ts +34 -32
  36. package/dist/core/runner/runtask.js +64 -34
  37. package/dist/core/runner-deps.d.ts +9 -2
  38. package/dist/core/swappable-deps.d.ts +90 -0
  39. package/dist/core/swappable-deps.js +55 -0
  40. package/dist/core/tool-policy.d.ts +26 -0
  41. package/dist/core/tool-policy.js +5 -1
  42. package/dist/core/wiring-manifest.d.ts +15 -1
  43. package/dist/core/wiring-manifest.js +10 -2
  44. package/dist/core/workflow-journal-store.d.ts +21 -2
  45. package/dist/core/workflow-journal-store.js +1 -1
  46. package/dist/engine/execution-env/node-execution-env.d.ts +2 -0
  47. package/dist/engine/execution-env/node-execution-env.js +2 -1
  48. package/dist/engine/harness/types.d.ts +11 -0
  49. package/dist/index.d.ts +5 -3
  50. package/dist/index.js +5 -3
  51. package/dist/orchestration/run-workflow-tool.d.ts +17 -0
  52. package/dist/orchestration/run-workflow-tool.js +12 -0
  53. package/dist/orchestration/workflow-observe.d.ts +1 -1
  54. package/dist/orchestration/workflow-observe.js +2 -0
  55. package/dist/orchestration/workflow-types.d.ts +37 -2
  56. package/dist/orchestration/workflow-types.js +16 -0
  57. package/dist/orchestration/workflow.d.ts +41 -2
  58. package/dist/orchestration/workflow.js +316 -48
  59. package/dist/stores/file/workflow-journal-store.js +10 -3
  60. package/package.json +1 -1
  61. package/test/export-surface.snapshot.json +51 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,59 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.10.0 — 2026-09-09
4
+
5
+ ### BREAKING — `Runner.swapModels` is retired into ONE hot-swap door, `Runner.swapDeps` (#628 + #616 seat door; @server S-174 @cli @client-core)
6
+ - **One door, a closed seat set.** `swapDeps(next)` hot-swaps any subset of `SWAPPABLE_DEP_SEATS = models | tiers | readFace` (new leaf `src/core/swappable-deps.ts`). The laws are stated once for every seat: the candidate is SCREENED whole before anything is written (`screenSwappableDeps`: the key set is the closed one, at least one seat, `tiers` rides with `models`, each seat's own value gate), the deps object is written ONCE (atomic across seats — a refused half leaves the other half unwritten), in-flight legs keep the snapshot they prepared under (the natural-snapshot sentence the catalog already had, now the rule for the whole set), and each seat is announced under its row in `SWAP_SEAT_NOTICE` (registered in CLOSED-SETS with its fence). Presence is the instruction: a present key is replaced (an explicit `undefined` clears an optional seat), an absent key is kept; an unknown key, an empty candidate and a non-object are refused loudly, never ignored.
7
+ - **Migration:** `runner.swapModels({ models, tiers })` → `runner.swapDeps({ models, tiers })` — same semantics, same `config.models_swapped` line, same base-URL advisory. No alias is kept. `@server` main.ts's one call site is the only known consumer.
8
+ - **The read-face seat is live (#628; @server S-174).** `swapDeps({ readFace: "open" | "roots" | undefined })` replaces `RunnerDeps.readFace` without a restart, through the very `assertReadFaceValue` gate every prepare door applies (same seat name, same message); a leg that already prepared keeps its face, every later prepare resolves against the new declaration. Announced as the new notice `config.read_face_swapped` (audience operator; `detail: { from, to, occurrenceId }`, `null` = no declaration; the SEAT is announced, not a leg's resolved face). `@server`: the `read-face` restart signal and the `readFaceEnvHeld` pipeline can be deleted — swap the late-arriving declaration in. Consumers: `ENGINE_NOTICE_CODES` +1 (a downstream audience mirror gains one row; server's `engine_notice` whitelist +1).
9
+ - Exports +6: `SWAPPABLE_DEP_SEATS`, `SWAP_SEAT_NOTICE`, `isSwappableDepSeat`, `screenSwappableDeps`, `SwappableDepSeat`, `SwappableDeps`.
10
+
11
+ ### Wire (additive) — the auto-mode classifier's unavailability: the per-ask fact and the per-session breaker read face (#616; @cli L-147 @server S-165 @client-core @web-admin @test)
12
+ - **CC 2.1.250 anchor.** An unavailable classifier is a BLOCK with `denialKind: "automode-unavailable"` there — the fact rides the outcome's denial-KIND axis beside provenance, never the origin vocabulary. This engine keeps its ruled divergence (the ask reaches a person instead of being denied), so the fact stays a FACT beside the origin word: `ASK_ORIGINS` is untouched (no `classifier_unavailable` origin; `client-core` `ASK_ORIGINS` needs no mirror change).
13
+ - **① the per-ask fact.** `AskRequest.classifierUnavailable?: { cause }` and the park row's twin `PendingAction.tool_approval.classifierUnavailable?` (echoed on the checkpoint summary) — present ⇔ the classifier was consulted on this ask and could not run; `cause` is the verdict's own word from the closed set `AUTO_MODE_UNAVAILABLE_CAUSES = error | timeout | breaker_open` (the requested `route_unresolved` is NOT a member: no classify-time site mints it — a broken derived route falls back at prepare, before any decide). Stamped at the gate's classifier station and the three inherited-lane stations on an `unavailable` verdict only; `parse_error` stamps nothing (the classifier ran and answered outside its contract — a different sentence). Origin, `requiresRealApproval` and every other member are byte-unchanged. `@cli`: render "asked because the classifier was unavailable (<cause>)" off this member; a card with the member absent is an ordinary ask.
14
+ - **② the session breaker read face.** `WiringManifest.autoMode.breaker?: { openedAtMs, lastCause, failures, runId }` — the most recent one-way breaker trip recorded for the SESSION on this Runner (an EARLIER leg's decider tripped; a decider is minted per run, so a leg-start manifest never reports its own leg — there is no `open` boolean because it would read `false` on every mint; the reason vocabulary keeps `latch_open` reserved for a mid-leg re-read face). `lastCause` is from the closed set `AUTO_MODE_BREAKER_CAUSES = error | timeout | parse_error` (what trips the latch; `onBreakerOpen.lastCause` is now typed by it). Present only after a trip; EXCLUDED from `configFingerprint` (a per-session observation, like `mcp`). The engine records the trip BEFORE calling the deployment's `onBreakerOpen` hook, so a deployment that wires no alarm still gets the read face. `@server` S-165: project `autoMode.breaker` onto `wiring_manifest` / `capabilities.permissionModeAuto` — do not mint a second cause vocabulary.
15
+ - Exports +8: `AUTO_MODE_UNAVAILABLE_CAUSES`, `isAutoModeUnavailableCause`, `AUTO_MODE_BREAKER_CAUSES`, `isAutoModeBreakerCause`, `AutoModeBreakerLedger`, `AutoModeUnavailableCause`, `AutoModeBreakerCause`, `AutoModeBreakerTrip`; `RunInternals.autoModeBreakerLedger` (trusted channel, Runner-set). CLOSED-SETS +2 rows.
16
+
17
+ ### Narrowing + BREAKING export — a `~/` path rule resolves against the EXECUTION ENVIRONMENT's home, never the engine process's (#644; @server S-184 @cli)
18
+ - **The rule.** The bases of a path pattern are a closed set, `PATH_RULE_BASES = cwd | root | home` (`PathRuleBases` keyed by it; `PATH_RULE_BASE_LABEL` names each). `home` is `ExecutionEnv.homeDir` — a NEW adapter-declared member (absolute path in the environment's own namespace); the local `NodeExecutionEnv` declares `os.homedir()` (it IS the host), so single-machine deployments are byte-identical. A pattern whose base the call cannot supply (absent, or not an absolute path) is now UNREADABLE — the persisted lane answers the fail-closed `rule_store_unavailable` ask (the reason names the base), the content-form lane its unreadable ask — never silence and never a guess against the engine host's home (the old `?? os.homedir()` fallback guarded the wrong directory on every remote/sandboxed leg). Zero rules that need the base ⇒ silence, as before. The same law now covers every base: a `/…` deny with no `root` used to reach nothing silently and is unreadable too; the `cwd` word's root stand-in (when no tracker moved) is the word's own meaning, unchanged.
19
+ - **Compile time too:** a content-form `~/` rule with no `pathBases.home` is refused as `unsupported.path_base` naming the base (the old `?? "/"` escape is gone).
20
+ - **The host's question.** `ruleBasesNeeded(rule): PathRuleBase[]` — which base a rule will need (`//abs` / subpath / command ⇒ none; `~/` ⇒ home; `/…` ⇒ root; relative ⇒ cwd), so a host can judge BEFORE prepare whether its environment can serve a rule. `createPermissionRuleLanes` takes the new required `home` seat; a declared `homeDir` that is not absolute is refused at prepare (`config.execution_env_home_dir_invalid`), never read as absent.
21
+ - **BREAKING export:** the boolean `pathRuleReaches` is REMOVED; `pathRuleReachOf(rule, target, bases): ProgramRunReachOutcome` (`reached` / `not_reached` / `unreadable` + reason) is the one path reach — a boolean invited a caller to read "cannot be judged" as "not reached". Exports −1 +5 (`pathRuleReachOf`, `ruleBasesNeeded`, `PATH_RULE_BASES`, `PATH_RULE_BASE_LABEL`, `PathRuleBase`). CLOSED-SETS +1 row. `@server` (remote-exec lane): declare `homeDir` on the remote ExecutionEnv adapter, or expect `~/` deny/ask rows to ask a person for those legs.
22
+
23
+ ### Fixes — a child run's thinking level follows the same seat rule as its model (#651)
24
+ - `roles.subagent = { systemPrompt, thinking: "high" }` with a parent that declared no thinking ran every child with thinking OFF: the delegation station inherited the parent's RESOLVED run level (`"off"` — the harness contract) as if it were an explicit declaration, and `spec.thinking` outranks the role preset. The child THINKING seat is now the model seat's sister (`childThinkingSeat`, `deploymentSubagentThinking`): explicit for the spawn (the agent definition's `thinking`; a fork keeps the caller's level) > the deployment's stated `roles.subagent.thinking` > the parent run's effective level (a mid-run degrade/boost still carries) > nothing. Three stations read it: the delegation station, the observer sidecar, the workflow mount's `parentThinking` getter. Nothing stated anywhere is byte-identical to before (the child runs at the parent's level).
25
+
26
+ ### Wire (additive) — the workflow lane joins the park family (#642 · B-049; @server S-185 @cli @client-core @web-admin)
27
+ - **A workflow child's ask PARKS instead of denying fail-closed.** The host's `durableApproval` opt-in now rides the Workflow mount beside `parentOnAsk` (`RunWorkflowToolDeps.parentDurableApproval`, minted from the same `spec.durableApproval` the delegation lanes read as `ctx.durableApprovalForChildren`) and is folded as `RunWorkflowOptions.defaultDurableApproval` into every spawned agent whose seat the deployment baseline left unpinned — both spawn lanes, the `authInherit` twin — **iff a `WorkflowRunStore` is wired** (the wa* row's durable home; `startWorkflow` refuses the pair without one, loudly; the tool never passes it storeless). With it, the workflow child whose inherited ask the host approver answers `unavailable` (or a durable mandate) parks exactly like a background delegation child under the §7.3 park family: a pending checkpoint whose `sourceTaskId` is the child session, the tool never runs, nobody is handed the deny sentence. Storeless deployments keep the previous lifecycle (the deny — now with the #648 notice).
28
+ - **Status word +1 (`WorkflowItemStatus`): `"parked"`** — an AGENT row's word only (phases/groups never park). **wa* row key +1: `parkedCheckpointToken`** (present iff `status:"parked"`; the mirror of `BackgroundAgentRecord.parkedCheckpointToken`) — the **workflow-origin join a host routes by**: a checkpoint whose `sourceTaskId` equals a `wa*` row's `sessionId` is a workflow child's park, and the row's token is its redemption key; **the checkpoint row itself gains no key** (the same one rule as the background join). `agent_end.status` may read `"parked"`; `deriveAgentDisplayStatus` gains `"parked"` (`AgentDisplayStatus` +1). The tool's `<usage>` block gains `agents_parked` when > 0. `@server`: the SQL `agents[]` JSON column takes the key with zero DDL; sdk `WorkflowItemStatus` +1 word +1 key. `@cli`: a `parked` wa* row renders as such (raw word until the dedicated card); the HITL path is the bg row's (`sourceTaskId` join).
29
+ - **The run SUSPENDS on a park (the team-discussion `teamPause` shape).** `ctx.agent()` / `ctx.agentStream().result()` throw `WorkflowAgentParkedError` (new export; code `workflow.agent_parked`, `WORKFLOW_AGENT_PARKED_ERROR_CODE`) — every later spawn is refused with the same error, and the park is the run's terminal whatever the script returns or throws (`status:"failed"`, `error` names the park, no `agentFailures` tally for a parked row). Previously the pause was mapped to a `failed` row (`unexpected.suspended`) and the script ran on past a pinned session and a checkpoint nobody could route — the stranded-suspend class. The completion notification's failed-path summary names the park ("workflow parked … do not re-issue the same call; the deployment resumes this run") instead of "workflow failed … re-invoke with resumeFromRunId".
30
+ - **Resume journal: a new entry arm, one-way.** `WorkflowJournalEntry` is now `{ callKey, result } | { callKey, parked }` — a parked ordinal is journaled under `parked` (the child's paused `TaskResult`: token, gate, pinned session, spend) with NO `result`, so a journal reader from before this release finds no terminal cause and REFUSES the resume (`WorkflowJournalIncompatibleError`) instead of admitting a paused cause it would re-run live beside the pinned child. **Type face:** `WorkflowJournalEntry` moves from `interface` to a discriminated `type`; `result` is optional on the union — a consumer reading `entry.result.terminal` must narrow (`entry.parked !== undefined` first). `@server`: the journal store's row schema must admit an entry with `parked` and no `result` (core's file store writes `{scope, ordinal, callKey, parked}` lines; the in-memory store is unchanged in shape).
31
+ - **A resume drives the parked child on.** `RunWorkflowOptions.parkedResume?: WorkflowParkedResume[]` (new export: `{ token, outcome, inheritedGate? }`; requires `resumeFromRunId`) — at a parked ordinal with a matching call key, a decision for its token makes the call `runner.resumeStream(token, outcome, <the script call's rebuilt spec>, …)` on the pinned session (single attempt; stall/throttle retries never fire for a drive) and settle the result at that ordinal, so the script continues from the parked call and the journal's ordinal becomes a completed result; **without a decision the ordinal RE-PARKS** (same token, no live run, the run suspends again). The ordinals after a driven one (a fan-out sibling that settled after the park) still replay — a drive is not a divergence. `RunInternals.workflowParkedResume?: Array<{ runId, token, outcome, inheritedGate? }>` (`workflowDepth`'s sibling, TRUSTED) is the per-run channel the Runner-mounted Workflow tool reads: a host that decided a workflow-origin park launches the host-session run that re-invokes `Workflow({resumeFromRunId})` with the decisions here, and the tool applies the ones naming the resumed run (`RunWorkflowToolDeps.parkedResume` getter). `@server S-185`: the decide route's third lane — route by the wa* join, then either the library `startWorkflow({resumeFromRunId, parkedResume})` or the run channel.
32
+ - **The resume chain never loses a park (two adversarial rounds, all findings adopted).** A parked ordinal is consulted outside the prefix-divergence latch; a parked ordinal whose CALL changed is refused (`workflow.parked_call_changed`, surfaced verbatim by the tool as a script fault) rather than respawned; a sibling ordinal parked in the prior journal re-parks even after the latch closed, and every prior-run park this run did not resolve is carried into its journal at the terminal, so a third-generation resume still finds it; a drive the resume ladder refuses pre-CAS re-parks on the prior token; a suspending run waits for its outstanding calls (counted from the record push) before its terminal, bounded by the run's cancel/deadline signal; a call queued behind a parked sibling under `concurrency:1` is refused, never launched. A park whose journal append fails past three attempts is cross-checked at resume against the prior run's row store (a `parked` row with no journal entry — or an unreadable store — refuses the resume).
33
+ - **Round 3 (adopted):** a driven leg whose terminal says the approved action never started (`resume.reopened_unstarted` / `resume.reopen_failed_unstarted`) re-parks — the checkpoint is not consumed work; a queued `agentStream` drive refused at the token ceiling re-parks (never a synthetic failed entry that would stop the carry); the carry-forward is bounded (the run's own signal; the claim-release cap per append); an oversize tombstone counts as this run's word at its ordinal.
34
+ - **Not in this slice (stated):** the workflow holds no checkpoint-store seat, so a re-park records what the journal says without probing the checkpoint (a park decided through the task-level `runner.resume` leg is reconciled by the host's join, not here); the row store is best-effort, so a STALE prior-run row beside a lost journal append is one documented residual, and a carry-forward whose appends all fail (the successor holds neither entry nor row for an unreached ordinal) is its second face — one design question (park truth from the checkpoint store rather than from best-effort records), left to a ruling. A parked wa* row emits no BCE `terminal` frame (the a* lane's park emits none either) — the row's state is the run record's.
35
+
36
+ ### Wire (additive) — `delegation.ask_unresolvable`: the fail-closed unavailable deny gets its person-facing half (#648; @server @cli @client-core)
37
+ - **Closed set +1.** `ENGINE_NOTICE_CODES` gains `delegation.ask_unresolvable` (`NOTICE_AUDIENCE` row **`user`**): an `ask` reached its FINAL deny with nobody having judged it — the approver consulted for the call (the run's own seat, or an ancestor's frozen seat on a delegated child: the sync / workflow lanes, or a background child outside the park family) answered `unavailable` and no durable park took the call afterwards. The deny is unchanged (`tool_end.gate.settlement.kind:"approver_unavailable"`, the sentence as the tool result); this is the half a person watching notices could not see. `detail: { sessionId, toolName, toolCallId, settlementKind, parkLaneExisted }` — `sessionId` REQUIRED (the child's session on a delegated child; lifted onto the typed key by the one throat), `parkLaneExisted` = a park lane was armed and declined/failed (`true`) or none existed (`false`). Once per tool call.
38
+ - **One mint.** `askUnresolvableNotice(facts)` (new export, `src/core/ask-unresolvable-notice.ts`, `@contract delegation.ask_unresolvable.single_mint`) composes it from the gate's deny-exit facts at the ONE exit both unavailable deny arms reach — the marked-call intercept (prepare-ask-lane) and the resolver's own arm (tool-policy) both land in gate-lanes after the park re-route has had its attempt (`ToolGateInput.onAskUnresolvable`, wired by prepare-gate-stations). A host driving the gate directly composes the same notice from the same facts.
39
+ - **Consumers:** a downstream audience mirror diffing against the catalog gains one `user` row; `@server`'s `engine_notice` whitelist +1; `@cli` renders one user-level warn line off `detail` (the child's session is the attribution — the card's `fromSubagent`/`sourceTaskId` badge path applies).
40
+
41
+ ## 7.9.2 — 2026-09-09
42
+
43
+ ### Fixes — a delegated child runs on the parent run's model, not the deployment's boot default (#641 · B-050; @cli @server)
44
+ - **One seat, one rule.** `childModelSeat` (subagent.ts) is now the only place an engine-spawned run's model is decided — the delegation child (`buildChildSpec`, every lane: sync / background / fork / revival) and the observer sidecar share it: an EXPLICIT model for the spawn (per-call `model` > agent definition `model` > tool-level `model`) > the deployment's STATED subagent tier (`roles.subagent` naming a model source of its own — the literal `"default"` spells "no tier of my own", a `select` form counts as stated) > the **parent run's effective model** (the caller's current `Model` object, per-model routing and credentials included) > the bare `subagent` role (only reachable outside a Runner-enriched ctx). The cell that changed is the last resort: it used to be `FALLBACK.subagent → "default"`, i.e. the boot default — a run whose main turn ran on model B spawned children on boot model A, and a bare deployment (catalog, no roles) spawned children that died at prepare with `No model for role "subagent"`. Both now run on the parent's model. A deployment that stated a cheap tier keeps it (that statement is operator intent; children do not follow the parent there).
45
+ - **Role identity rides the seat.** Every non-explicit child spec carries `modelRole: "subagent"` beside the inherited `model`, so a deployment's `roles.subagent` `systemPrompt` / `thinking` preset still applies when the model is the parent's (the explicit-model branch of `resolveTaskModel` keys presets on the role).
46
+ - **Precedence note (definition lane).** The tool-level `model` (`createSubagentTool({ model })`) is an explicit seat and now outranks "inherit the parent" for a definition that names no model; it used to sit below the parent snapshot on that lane only. Fork children are unchanged (always the caller's model).
47
+ - **The workflow lane reads the same seat.** A model-less script child (`agent()` / `agentStream()`) used to take the host's `Model` unconditionally; the workflow mount's host-model getter now folds the deployment's stated tier first (`deploymentSubagentTierModel(runnerSelf.agentCatalog) ?? host model`, prepare-caps-and-workflow.ts — read off the LIVE catalog per call, so a `swapModels` between two spawns is seen by the second), so the Agent lanes and the workflow lane can no longer answer one deployment two ways. A script `model` and an `agentType` model keep outranking it (explicit), the allow-list keeps gating script words. A stated tier whose catalog ref is dead fails THAT call on the recorded path (failed agent record + `agent_end`, then the throw the script's own `catch` sees — the unknown-`agentType` shape; the next call's slot is released), never a substituted model. Both lanes' pre-launch catch now stamps an ordinary throw's message as the record's `errorMessage` (no machine code minted), so the row says why the ordinal has no child — previously only a review refusal or a budget stop named its cause.
48
+ - **The durable row names the inherited model.** A background child's record (`model`, the tier-3 revival key) records the seat's model — the parent's id for an inherited child, exactly what the child's own ticks say — so a revival from another parent keeps running the model the row was spawned on instead of silently moving to the reviver's; a stated-tier child's row stays blank as before (the role resolves it).
49
+ - **Read face (additive, @server @cli):** `Runner.agentCatalog` / `RunnerSelfSeat.agentCatalog` gains `roles?: ModelRoles` (a copy of `RunnerDeps.roles`) — the seat's reader. No wire change; no request-lane roles seat (the deployment/request boundary stands).
50
+ - **Engine side channels are unchanged by design** (`@contract roles.side_channels_deployment_only`): the auto-mode classifier, the compaction/summarize lane, memory consolidation and next-prompt suggestion resolve on the deployment roles alone and never take a run's model — they are deployment tools, not children of the run.
51
+ - Consumers: `@cli` the `_subagent-model-follows-run-live-pty.mjs` probe's `EXPECTED_VERDICT=b` door flips to `a` on this engine; `@server` zero code (`roles.subagent = "default"` when `MODEL_CHEAP_ID` is unset is exactly the "not stated" spelling).
52
+
53
+ ### Docs — the sub-run inheritance matrix (#643; @cli @server @test)
54
+ - `docs/SUBRUN-INHERITANCE.md`: every inheritance face (model, approver seat + park facility, posture + spawn review, inherited gate + ancestor rules, thinking, role fallback, budgets/TTLs, event-forwarding identity keys, principal + auth hook, session/run lineage, contentOrigin) × every child lane (Agent sync / background / workflow agents / fork·team·synthesize / scheduler wake-up / observer·verifier·advisor / resume-rebuilt / tier-3 revival), each cell "inherits / falls back to (file:line) / gated?". Appendix A lists every deployment-default fallback position with its verdict (design → contract sentence; defect → ticket); Appendix B lists every fail-closed deny mouth with who reaches it and where it is visible.
55
+ - **B-049 (#642) is DEFINED here, not fixed in this release** (see the matrix §B-049): a workflow-lane child whose inherited ask the host approver answers `unavailable` denies fail-closed exactly like a SYNC delegation child (pinned three-lane: `test/backlog642-child-ask-lanes.test.ts`); only the BACKGROUND lane parks, because only it is handed the parent's `durableApproval` (§7.3 park family). Making workflow children park is the same park family extended to the workflow row (`wa*` row parked state + the workflow's own pause cascade + a resume path) — a design slice, scheduled separately; the interim is the loud terminal (the deny sentence in the tool result and `tool_end.gate.settlement.kind = "approver_unavailable"`).
56
+
3
57
  ## 7.9.1 — 2026-09-08
4
58
 
5
59
  ### Wire (additive) — `mcp.injection_dropped`: the request-lane MCP drop reaches the user (L-167; @server @cli @client-core)
@@ -0,0 +1,81 @@
1
+ import type { Model, ModelRef, ModelRoles, ThinkingLevel } from "../core/types.js";
2
+ /** The deployment role map behind the runner (`Runner.agentCatalog.roles`) — same posture as `runnerModelCatalog` (subagent.ts):
3
+ * host code, a throwing/absent stand-in reads as "no role map", never as a failed delegation. */
4
+ export declare function runnerRoleMap(runner: unknown): ModelRoles | undefined;
5
+ /**
6
+ * Did the deployment STATE a model for its subagents? True iff `roles.subagent` names a model source of its
7
+ * own: a string ref other than the literal `"default"`, a `Model` object, or a `{ model | select }` config
8
+ * whose `model` is not that literal. The word `"default"` is how a deployment spells "no subagent tier of my
9
+ * own — same as the main model" (a catalog key of that name is the boot default), so it reads as NOT stated;
10
+ * a role entry carrying only a `thinking` / `systemPrompt` preset states no model either.
11
+ */
12
+ export declare function deploymentStatesSubagentModel(roles: ModelRoles | undefined): boolean;
13
+ /**
14
+ * Did the deployment STATE a thinking level for its subagents — and which? The `thinking` member of an
15
+ * object-form `roles.subagent` entry; a string ref, a `Model` object or an entry without the member states
16
+ * none (`undefined`). The thinking axis's twin of {@link deploymentStatesSubagentModel}: the SAME seat is
17
+ * read (`roles.subagent` only — never the fallback chain, whose thinking the parent's own effective level
18
+ * already reflects when the parent ran on it).
19
+ */
20
+ export declare function deploymentSubagentThinking(roles: ModelRoles | undefined): ThinkingLevel | undefined;
21
+ /**
22
+ * The child run's THINKING seat — {@link childModelSeat}'s sister, the same rung order on the other axis
23
+ * (#651): an EXPLICIT level for this spawn wins (the agent definition's `thinking`; a fork's explicit is the
24
+ * caller's own level, as its model is the caller's); else the deployment's STATED subagent level
25
+ * ({@link deploymentSubagentThinking}); else the PARENT run's effective level (the harness's current level —
26
+ * a mid-run degrade/boost carries into new children); else nothing (the role chain decides, as before).
27
+ *
28
+ * The stated level is WRITTEN onto the spec rather than left to the role chain, unlike the model axis,
29
+ * because the two axes resolve differently inside `resolveTaskModel`: a stated MODEL is reached through the
30
+ * seat's role identity (`modelRole: "subagent"`), which an explicit-model seat does not carry — leaving the
31
+ * level unwritten there would silently hand an explicit-model child the `default` role's level instead of
32
+ * the stated subagent one. Writing the literal is the same value the role chain would have produced, on
33
+ * every seat shape.
34
+ *
35
+ * Why the parent's level used to be wrong here: the parent's RESOLVED run level (`"off"` when the parent
36
+ * declared nothing — the harness contract) was inherited as if it were an explicit declaration, and
37
+ * `spec.thinking` outranks the role preset, so a deployment that stated `roles.subagent.thinking` watched
38
+ * every child run with thinking off. The parent's level is the LAST rung now, where the model axis has it.
39
+ */
40
+ export declare function childThinkingSeat(explicit: ThinkingLevel | undefined, deploymentStated: ThinkingLevel | undefined, parent: ThinkingLevel | undefined): {
41
+ thinking?: ThinkingLevel;
42
+ };
43
+ /**
44
+ * The deployment's stated subagent tier as a resolved `Model` — for a spawn lane that needs the concrete
45
+ * model up front (the workflow lane keys call identity and the display label on it), so both lanes read the
46
+ * same rule: `undefined` when the deployment stated nothing (the caller then falls to the parent's model),
47
+ * else the role chain's answer for `subagent`. A stated ref the catalog cannot resolve throws here, loudly,
48
+ * exactly where a child's own prepare would have thrown.
49
+ */
50
+ export declare function deploymentSubagentTierModel(deps: {
51
+ models?: Record<string, Model>;
52
+ roles?: ModelRoles;
53
+ } | undefined): Model | undefined;
54
+ /**
55
+ * The child run's model seat — ONE rule for every engine-spawned run (the delegation child at
56
+ * `buildChildSpec`, the observer sidecar): an EXPLICIT model for this spawn wins (per-call `model`, the
57
+ * agent definition's `model`, the tool-level `model`); else a deployment that STATED a subagent tier gets
58
+ * it (`{ modelRole: "subagent" }` — the role chain resolves the stated entry, `select` forms included);
59
+ * else the child runs on the PARENT run's effective model (the caller's current `Model` object, so
60
+ * per-model routing/credentials ride along); else — no parent model readable, which only happens outside
61
+ * a Runner-enriched ctx — the bare role, whose chain ends at the deployment default.
62
+ *
63
+ * The role identity `modelRole: "subagent"` stays on every non-explicit seat: the explicit-model branch of
64
+ * `resolveTaskModel` keys the role's `systemPrompt` / `thinking` presets on it, so a deployment's
65
+ * `roles.subagent` preset is not lost when the MODEL is inherited from the parent.
66
+ *
67
+ * Why "default" was the wrong last resort: `FALLBACK.subagent = ["default"]` resolves to the deployment's
68
+ * boot default, which is not the model the parent run was switched to — a run whose main turn was moved to
69
+ * model B by the caller spawned children on boot model A (and a deployment whose boot default was removed
70
+ * from the roster spawned children that failed at prepare). The parent's effective model is the honest
71
+ * default; the deployment's stated tier keeps outranking it because that statement is operator intent.
72
+ */
73
+ /** The seat: an explicit ref alone, or the role identity with an optional inherited Model (see {@link childModelSeat}). */
74
+ export type ChildModelSeat = {
75
+ model: ModelRef;
76
+ modelRole?: undefined;
77
+ } | {
78
+ modelRole: "subagent";
79
+ model?: Model;
80
+ };
81
+ export declare function childModelSeat(explicit: ModelRef | undefined, deploymentStated: boolean, parent: Model | undefined): ChildModelSeat;
@@ -0,0 +1,46 @@
1
+ import { resolveTaskModel } from "../core/roles.js";
2
+ export function runnerRoleMap(runner) {
3
+ try {
4
+ return runner?.agentCatalog?.roles;
5
+ }
6
+ catch {
7
+ return undefined;
8
+ }
9
+ }
10
+ export function deploymentStatesSubagentModel(roles) {
11
+ const s = roles?.subagent;
12
+ if (s == null)
13
+ return false;
14
+ if (typeof s === "string")
15
+ return s !== "default";
16
+ if ("id" in s && "api" in s)
17
+ return true;
18
+ const cfg = s;
19
+ if (cfg.model !== undefined)
20
+ return cfg.model !== "default";
21
+ return cfg.select !== undefined;
22
+ }
23
+ export function deploymentSubagentThinking(roles) {
24
+ const s = roles?.subagent;
25
+ if (s == null || typeof s === "string")
26
+ return undefined;
27
+ if ("id" in s && "api" in s)
28
+ return undefined;
29
+ return s.thinking;
30
+ }
31
+ export function childThinkingSeat(explicit, deploymentStated, parent) {
32
+ const level = explicit ?? deploymentStated ?? parent;
33
+ return level !== undefined ? { thinking: level } : {};
34
+ }
35
+ export function deploymentSubagentTierModel(deps) {
36
+ return deps !== undefined && deploymentStatesSubagentModel(deps.roles) ? resolveTaskModel({ modelRole: "subagent" }, deps).model : undefined;
37
+ }
38
+ export function childModelSeat(explicit, deploymentStated, parent) {
39
+ if (explicit !== undefined)
40
+ return { model: explicit };
41
+ if (deploymentStated)
42
+ return { modelRole: "subagent" };
43
+ if (parent !== undefined)
44
+ return { modelRole: "subagent", model: parent };
45
+ return { modelRole: "subagent" };
46
+ }
@@ -1,5 +1,6 @@
1
1
  import type { RunInternals, RunnerSelfSeat } from "../core/runner/contracts.js";
2
2
  import type { AgentDefinition, Model, ModelRef, TaskLimits, TerminalCause, ToolSpec } from "../core/types.js";
3
+ export { childThinkingSeat, deploymentSubagentThinking, deploymentSubagentTierModel } from "./child-model-seat.js";
3
4
  import { type ExecutionEnv } from "../internal/harness.js";
4
5
  import type { TaskNotificationPayload } from "../core/task-notification.js";
5
6
  import { RETAIN_DEFAULT_TTL_MS, RETAIN_DEFAULT_MAX } from "../config/defaults.js";
@@ -562,7 +563,7 @@ export declare function delegationEntryLedgerFootprint(registry: object): {
562
563
  * they were prepared with.
563
564
  *
564
565
  * The MODEL roster ({@link SubagentToolOptions.models}) deliberately does NOT follow that rule when it
565
- * is left to the runner: a hot-swapped catalog is a first-class runner verb (`Runner.swapModels`) with
566
+ * is left to the runner: a hot-swapped catalog is a first-class runner verb (`Runner.swapDeps`) with
566
567
  * no rebuild step, so the tool re-reads it at each judgement instead of requiring a remount. Only the
567
568
  * schema/announce projection of it stays a mount-time snapshot (cache-prefix stability). Passing
568
569
  * `models` explicitly opts back into snapshot semantics for that table — see the field.
@@ -679,12 +680,13 @@ export interface SubagentToolOptions {
679
680
  */
680
681
  ensureChildSessionDurable?: (sessionId: string) => Promise<void>;
681
682
  };
682
- /** Model the child runs on. If omitted, the child resolves the `subagent` role ( `default`). */
683
+ /** Model the child runs on when neither the call nor the agent definition names one (the tool-level explicit seat).
684
+ * Omitted too ⇒ the deployment's stated `roles.subagent` tier, else the parent run's effective model (`childModelSeat`). */
683
685
  model?: ModelRef;
684
686
  /**
685
687
  * Declarative sub-agents the model can pick from (design/38 1B). When set, the tool exposes an `agent`
686
688
  * parameter (enum of these names) and the chosen definition's config builds the child task — its
687
- * `allowTools`/`denyTools` REPLACE the tool-level ones, `model` omitted = inherit the caller's model.
689
+ * `allowTools`/`denyTools` REPLACE the tool-level ones, `model` omitted = tool-level `model` > stated `roles.subagent` tier > the caller's model (`childModelSeat`).
688
690
  * design/141 件A: when OMITTED, defaults to the runner's deployment catalog (`RunnerDeps.agents`) —
689
691
  * one Runner, one registry across the Agent-tool and workflow lanes. Pass explicitly to narrow.
690
692
  */
@@ -7,6 +7,8 @@ import { newDelegationProvenanceAggregate, reduceDelegationAttestation } from ".
7
7
  import { registerDelegationLaunch, replayExternalSettlementEffects, settleDelegation } from "../core/memory-engine/delegation-settlement.js";
8
8
  import { enqueueMemoryAnnouncement } from "../core/memory-engine/layout.js";
9
9
  import { resolveModel, resolveModelDisplayLabel } from "../core/roles.js";
10
+ import { childModelSeat, childThinkingSeat, deploymentStatesSubagentModel, deploymentSubagentThinking, runnerRoleMap } from "./child-model-seat.js";
11
+ export { childThinkingSeat, deploymentSubagentThinking, deploymentSubagentTierModel } from "./child-model-seat.js";
10
12
  import { OUTPUT_TOOL_NAME, REPORT_BLOCKED_TOOL_NAME } from "../core/runner/synthetic-tools.js";
11
13
  import { TOOL_SEARCH_NAME } from "../core/runner/tool-disclosure.js";
12
14
  import { OFFLOAD_TOOL_NAME } from "../core/tool-result-store.js";
@@ -1466,7 +1468,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1466
1468
  })),
1467
1469
  model: Type.Optional(Type.String({
1468
1470
  description: rosterNames !== undefined && rosterNames.length > 0
1469
- ? `Optional model override for this agent. Takes precedence over the agent definition's model. If omitted, uses the agent definition's model, or inherits from the parent. A value outside this deployment's model catalog is REFUSED — the call does not fall back silently. Ignored for subagent_type: "${FORK_SUBAGENT_TYPE}" — forks always inherit the parent model.`
1471
+ ? `Optional model override for this agent. Takes precedence over the agent definition's model. If omitted, uses the agent definition's model, else the deployment's subagent tier, else inherits from the parent. A value outside this deployment's model catalog is REFUSED — the call does not fall back silently. Ignored for subagent_type: "${FORK_SUBAGENT_TYPE}" — forks always inherit the parent model.`
1470
1472
  : `Optional model override request. No model catalog is configured here, so a requested name cannot be resolved — the agent runs its definition's model (or inherits from the parent) and the result notes the override was not applied. Ignored for subagent_type: "${FORK_SUBAGENT_TYPE}".`,
1471
1473
  })),
1472
1474
  isolation: Type.Optional(Type.Literal("worktree", {
@@ -1784,9 +1786,9 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1784
1786
  const childTools = def
1785
1787
  ? resolveToolSubset(childPool, def.allowTools, def.denyTools)
1786
1788
  : resolveToolSubset(childPool, opts.allowTools, opts.denyTools);
1787
- let childModel = wantsFork
1788
- ? ctx.model ?? opts.model
1789
- : perCallModel ?? (def ? (def.model ?? ctx.model ?? opts.model) : opts.model);
1789
+ const explicitModelChain = wantsFork ? [ctx.model, opts.model] : [perCallModel, def?.model, opts.model];
1790
+ let childModel = explicitModelChain.find((c) => c !== undefined);
1791
+ const deploymentStatedSubagentModel = deploymentStatesSubagentModel(runnerRoleMap(opts.runner));
1790
1792
  if (reviveClaim !== undefined && typeof childModel === "string") {
1791
1793
  const resolvable = (ref) => {
1792
1794
  try {
@@ -1799,24 +1801,26 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1799
1801
  };
1800
1802
  if (!resolvable(childModel)) {
1801
1803
  const dead = childModel;
1802
- const rest = def !== undefined ? [ctx.model, opts.model] : [opts.model];
1804
+ const rest = explicitModelChain.slice(explicitModelChain.indexOf(dead) + 1);
1803
1805
  childModel = rest.find((c) => c !== undefined && (typeof c !== "string" || resolvable(c)));
1804
1806
  if (modelNote === undefined) {
1805
1807
  modelNote = `note: model "${inlineUntrusted(dead, 80)}" from this agent type's configuration is not on the current model roster and was NOT applied — the revived agent ran on its default (inherited) model.`;
1806
1808
  }
1807
1809
  }
1808
1810
  }
1809
- const spawnModel = typeof childModel === "string"
1811
+ const childSeat = childModelSeat(childModel, deploymentStatedSubagentModel, ctx.model);
1812
+ const seatModel = childSeat.model;
1813
+ const spawnModel = typeof seatModel === "string"
1810
1814
  ? (() => {
1811
1815
  try {
1812
- return resolveModel(childModel, judgedModels).id;
1816
+ return resolveModel(seatModel, judgedModels).id;
1813
1817
  }
1814
1818
  catch {
1815
- return resolveModelDisplayLabel(childModel);
1819
+ return resolveModelDisplayLabel(seatModel);
1816
1820
  }
1817
1821
  })()
1818
- : childModel?.id;
1819
- const rowModelKey = perCallModel !== undefined ? requestedModel ?? perCallModel.id : typeof childModel === "string" ? childModel : childModel?.id;
1822
+ : seatModel?.id;
1823
+ const rowModelKey = perCallModel !== undefined ? requestedModel ?? perCallModel.id : typeof seatModel === "string" ? seatModel : seatModel?.id;
1820
1824
  const childSystemPrompt = def?.systemPrompt ?? opts.systemPrompt;
1821
1825
  const childDefaultPersona = childSystemPrompt === undefined && !wantsFork ? SUBAGENT_PROMPT : undefined;
1822
1826
  const defMaxTurns = typeof def?.maxTurns === "number" && Number.isFinite(def.maxTurns) && def.maxTurns > 0 ? def.maxTurns : undefined;
@@ -1888,7 +1892,8 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1888
1892
  sessionId: sid,
1889
1893
  ...(resume ? { requireExistingSession: true } : {}),
1890
1894
  objective: objectiveText,
1891
- ...(ctx.model !== undefined ? { model: ctx.model } : { modelRole: "subagent" }),
1895
+ ...childModelSeat(undefined, deploymentStatesSubagentModel(runnerRoleMap(opts.runner)), ctx.model),
1896
+ ...childThinkingSeat(undefined, deploymentSubagentThinking(runnerRoleMap(opts.runner)), ctx.thinkingLevel),
1892
1897
  ...(observerDef.systemPrompt !== undefined && !resume ? { systemPrompt: observerDef.systemPrompt } : {}),
1893
1898
  ...(ctx.principal !== undefined ? { principal: ctx.principal } : {}),
1894
1899
  ...(ctx.getApiKeyAndHeaders !== undefined ? { getApiKeyAndHeaders: ctx.getApiKeyAndHeaders } : {}),
@@ -2128,7 +2133,8 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2128
2133
  ...(reviveClaim === undefined && ctx.fileHistoryLineage !== undefined ? { fileHistoryLineage: ctx.fileHistoryLineage } : {}),
2129
2134
  ...(ctx.onSubagentSpawn ? { onSubagentSpawn: ctx.onSubagentSpawn } : {}),
2130
2135
  };
2131
- const childThinking = def?.thinking ?? ctx.thinkingLevel;
2136
+ const explicitThinking = wantsFork ? ctx.thinkingLevel : def?.thinking;
2137
+ const childThinking = childThinkingSeat(explicitThinking, deploymentSubagentThinking(runnerRoleMap(opts.runner)), ctx.thinkingLevel);
2132
2138
  const provenanceAgentName = agentName ?? def?.name;
2133
2139
  const childMemoryPersistenceCapable = ctx.memoryPersistenceCapable === false ? false : (def?.memoryPersistenceCapable ?? ctx.memoryPersistenceCapable);
2134
2140
  const childOnAsk = ctx.onAsk !== undefined
@@ -2140,8 +2146,8 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2140
2146
  : undefined;
2141
2147
  const buildChildSpec = (signal) => ({
2142
2148
  objective: prompt,
2143
- ...(childModel ? { model: childModel } : { modelRole: "subagent" }),
2144
- ...(childThinking !== undefined ? { thinking: childThinking } : {}),
2149
+ ...childSeat,
2150
+ ...childThinking,
2145
2151
  tools: childTools,
2146
2152
  systemPrompt: childSystemPrompt,
2147
2153
  ...(def?.memory ? { memory: def.memory } : {}),
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `delegation.ask_unresolvable` — the one mint of the notice the gate raises when an `ask` reached its
3
+ * final deny with NOBODY having judged it: the approver consulted for the call (the run's own seat, or an
4
+ * ancestor's frozen seat on a delegated child) answered the ROUTING question `unavailable` — no operator
5
+ * is reachable for this ask — and no durable approval park took the call afterwards. The deny itself is
6
+ * older than this notice and stands unchanged (the model reads the sentence as its tool result, the wire
7
+ * carries `tool_end.gate.settlement.kind:"approver_unavailable"`); what was missing was any face a PERSON
8
+ * watching notices could see. An operator reading notices alone saw nothing, and the person whose
9
+ * delegated work was refused learned it only by reading the child's transcript.
10
+ *
11
+ * Two deny arms compose this settlement — the marked-call intercept (an ancestor's seat answered
12
+ * unavailable and the float found no park) and the resolver's own unavailable arm — and both reach ONE
13
+ * exit in the gate lanes, after the park re-route has had its one attempt. That exit is the only caller of
14
+ * this factory: one call, one notice, once per tool call.
15
+ *
16
+ * Audience `"user"`: the refused work is the user's own (their run, or a delegation their run made), and
17
+ * the recovery — a checkpoint store with a durable approval opt-in so such asks can wait for a person, or
18
+ * a live approver reachable for delegated children — is a deployment posture the operator hears through
19
+ * the sink like every other code; the row only adds the session-stream projection so the person whose
20
+ * call was refused sees WHY it did not run. `sessionId` is therefore REQUIRED at the mint (the routing
21
+ * half of a user row). Dedup unit: per tool call — the gate's obligation (it owns the call); this module
22
+ * is pure.
23
+ */
24
+ import type { EngineNotice } from "./engine-notice.js";
25
+ import type { SettlementKind } from "./gate-outcome.js";
26
+ /** What the gate knows at the deny exit — facts only, no words of the engine's vocabulary composed. */
27
+ export interface AskUnresolvableFacts {
28
+ /** The session whose call was refused — the notice's routing key, required (see module note). */
29
+ readonly sessionId: string;
30
+ /** The tool the ask was for (the message quotes it verbatim). */
31
+ readonly toolName: string;
32
+ /** The refused call's id — the correlation key to the `tool_end` frame carrying the same settlement. */
33
+ readonly toolCallId: string;
34
+ /** The settlement the deny composed — always `approver_unavailable` today; carried as a fact, not re-derived. */
35
+ readonly settlementKind: Extract<SettlementKind, "approver_unavailable">;
36
+ /** Whether a durable approval park lane was ARMED for this run (`true`: the park had its one attempt and
37
+ * did not take the call — it declined or failed; `false`: no park lane at all, the usual shape). */
38
+ readonly parkLaneExisted: boolean;
39
+ }
40
+ /**
41
+ * Compose the `delegation.ask_unresolvable` notice from the gate's deny-exit facts.
42
+ *
43
+ * @contract delegation.ask_unresolvable.single_mint — the `delegation.ask_unresolvable` notice is composed
44
+ * by CORE from the gate's deny-exit FACTS (session, tool, call id, the composed settlement, whether a park
45
+ * lane existed) at the ONE exit both unavailable deny arms reach; no other site spells the code or the
46
+ * sentence, and an empty session or tool name is refused at the mint with a `TypeError` rather than
47
+ * shaped into a notice that would route nowhere.
48
+ *
49
+ * `detail: { sessionId, toolName, toolCallId, settlementKind, parkLaneExisted }` — `sessionId` is lifted
50
+ * onto the typed key by {@link import("./engine-notice.js").deliverEngineNotice}, the one throat.
51
+ */
52
+ export declare function askUnresolvableNotice(facts: AskUnresolvableFacts): EngineNotice;
@@ -0,0 +1,25 @@
1
+ export function askUnresolvableNotice(facts) {
2
+ const { sessionId, toolName, toolCallId, settlementKind, parkLaneExisted } = facts;
3
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
4
+ throw new TypeError("delegation.ask_unresolvable: sessionId is required — a user-audience notice with no session routes nowhere");
5
+ }
6
+ if (typeof toolName !== "string" || toolName.length === 0) {
7
+ throw new TypeError("delegation.ask_unresolvable: toolName is required");
8
+ }
9
+ if (typeof toolCallId !== "string" || toolCallId.length === 0) {
10
+ throw new TypeError("delegation.ask_unresolvable: toolCallId is required");
11
+ }
12
+ if (settlementKind !== "approver_unavailable") {
13
+ throw new TypeError(`delegation.ask_unresolvable: settlementKind ${JSON.stringify(settlementKind)} is not the unavailable settlement`);
14
+ }
15
+ const park = parkLaneExisted
16
+ ? "the durable approval park did not take it"
17
+ : "no durable approval park is armed for this run";
18
+ return {
19
+ code: "delegation.ask_unresolvable",
20
+ message: `approval for "${toolName}" could not be resolved: the approver reported that nobody is reachable for this ask and ${park} — ` +
21
+ `the call was denied fail-closed and did not run. To let such approvals wait for a person, run with a checkpoint store and a ` +
22
+ `durable approval opt-in (or a live approver reachable for delegated children).`,
23
+ detail: { sessionId, toolName, toolCallId, settlementKind, parkLaneExisted: parkLaneExisted === true },
24
+ };
25
+ }
@@ -1,4 +1,16 @@
1
1
  import type { ToolCallRequest } from "./tool-policy.js";
2
+ /**
3
+ * WHY an `unavailable` verdict could not run — the closed set behind the verdict arm, the ask's
4
+ * `classifierUnavailable` fact (the card's "asked because the classifier could not run (timeout)" sentence)
5
+ * and the `auto_mode.classified` trace frame's `cause`, so the three spell one word. `error` = the model leg
6
+ * threw or rejected (a route failure at classify time reads here too — the derived-route pre-flight fell
7
+ * back BEFORE any decide, so there is no separate word for it); `timeout` = the round-trip cap;
8
+ * `breaker_open` = the session latch already tripped and the round was short-circuited.
9
+ */
10
+ export declare const AUTO_MODE_UNAVAILABLE_CAUSES: readonly ["error", "timeout", "breaker_open"];
11
+ export type AutoModeUnavailableCause = (typeof AUTO_MODE_UNAVAILABLE_CAUSES)[number];
12
+ /** Whether a value is a member of the unavailable-cause set — the screen a row reader applies to a stored word. */
13
+ export declare function isAutoModeUnavailableCause(v: unknown): v is AutoModeUnavailableCause;
2
14
  /** The classifier's verdict over one pending `ask`. */
3
15
  export type AutoModeVerdict = {
4
16
  kind: "allow";
@@ -12,13 +24,23 @@ export type AutoModeVerdict = {
12
24
  /** The classifier could not run: model error/timeout, or the session breaker is open. NOT a decision. */
13
25
  | {
14
26
  kind: "unavailable";
15
- cause: "error" | "timeout" | "breaker_open";
27
+ cause: AutoModeUnavailableCause;
16
28
  }
17
29
  /** The model responded but not in the `<block>…` contract shape. NOT a decision. */
18
30
  | {
19
31
  kind: "parse_error";
20
32
  raw: string;
21
33
  };
34
+ /**
35
+ * The failure kinds that COUNT toward the one-way breaker — and the word a trip records as its `lastCause`:
36
+ * the two health failures of the unavailable arm plus the contract failure. `breaker_open` is deliberately
37
+ * not one: it is what the latch ANSWERS once tripped, never what trips it; a cancelled round (the run's own
38
+ * abort) counts nothing (see the decider's catch arm).
39
+ */
40
+ export declare const AUTO_MODE_BREAKER_CAUSES: readonly ["error", "timeout", "parse_error"];
41
+ export type AutoModeBreakerCause = (typeof AUTO_MODE_BREAKER_CAUSES)[number];
42
+ /** Whether a value is a member of the breaker-cause set (the manifest's value screen over a recorded trip). */
43
+ export declare function isAutoModeBreakerCause(v: unknown): v is AutoModeBreakerCause;
22
44
  /**
23
45
  * Parse the 207 classifier output contract:
24
46
  * blocked: `<block>yes</block><category>Rule Name</category><reason>[Rule Name] …</reason>`
@@ -65,10 +87,11 @@ export interface AutoModeDeciderOptions {
65
87
  /** Consecutive-failure threshold that opens the session breaker (default 3 "连续 N 失败").
66
88
  * Failures = unavailable(error|timeout) + parse_error. A successful round (allow/block) resets it. */
67
89
  failureThreshold?: number;
68
- /** Fired ONCE when the breaker opens ("本 session 退回非 auto + 一次性告警"). */
90
+ /** Fired ONCE when the breaker opens ("本 session 退回非 auto + 一次性告警"). `lastCause` is the failure that tripped it
91
+ * ({@link AutoModeBreakerCause}). */
69
92
  onBreakerOpen?: (info: {
70
93
  consecutiveFailures: number;
71
- lastCause: string;
94
+ lastCause: AutoModeBreakerCause;
72
95
  }) => void;
73
96
  /**
74
97
  * Fired once per `decide` call with the verdict the gate is about to act on and the wall time it
@@ -117,6 +140,42 @@ export interface AutoModeDecider {
117
140
  * One instance per run/session — the breaker state is the session's "退回非 auto" latch.
118
141
  */
119
142
  export declare function createAutoModeDecider(opts: AutoModeDeciderOptions): AutoModeDecider;
143
+ /**
144
+ * ONE breaker trip as the session-level read face records it (`WiringManifest.autoMode.breaker`): when the
145
+ * latch opened, what tripped it, how many consecutive failures it took, and which run's decider it was.
146
+ * A decider is minted per run (its latch is a RUN fact), so a trip names one leg; the ledger below carries
147
+ * the most recent one forward per session, which is how the session's NEXT leg can say "auto mode fell
148
+ * back to asking on this session, at T, because the classifier timed out three times" — the sentence a
149
+ * shell's doctor line and a capabilities read face owe a person who wonders why auto mode is asking.
150
+ */
151
+ export interface AutoModeBreakerTrip {
152
+ /** Epoch ms at which the latch opened. */
153
+ readonly openedAtMs: number;
154
+ /** The failure that tripped it — the streak's last failure ({@link AutoModeBreakerCause}). */
155
+ readonly lastCause: AutoModeBreakerCause;
156
+ /** The consecutive-failure count at the trip (the threshold, or more under concurrent rounds). */
157
+ readonly failures: number;
158
+ /** The run whose decider tripped. */
159
+ readonly runId: string;
160
+ }
161
+ /**
162
+ * The Runner-lived, per-session record of the most recent breaker trip — the ONE writer is the engine's
163
+ * own `onBreakerOpen` wrap at the arming site (the deployment's alarm hook, when wired, is called after
164
+ * the record lands), the ONE reader the wiring-manifest phase of a later leg. Never persisted (a trip is
165
+ * process-local, like the decider it describes); bounded FIFO so a long-lived Runner cannot grow it
166
+ * without limit — an evicted session simply reads as "no trip recorded", the same as a session that
167
+ * never tripped. Threaded through the trusted `RunInternals` channel exactly as the per-session read-file
168
+ * seats are (the Runner sets it on every prepare it drives; a standalone prepare has none).
169
+ */
170
+ export declare class AutoModeBreakerLedger {
171
+ private readonly cap;
172
+ private readonly trips;
173
+ constructor(cap?: number);
174
+ /** Record THIS session's most recent trip (replacing an earlier one). */
175
+ record(sessionId: string, trip: AutoModeBreakerTrip): void;
176
+ /** The most recent trip recorded for this session, or undefined (never tripped, or evicted). */
177
+ lastTrip(sessionId: string): AutoModeBreakerTrip | undefined;
178
+ }
120
179
  /** The deployment's bounds for the denial limit (`RunnerDeps.autoMode.denialLimit`). Every member
121
180
  * optional; an omitted member takes its CC default. A present member with a bad value is REFUSED
122
181
  * loudly at construction (never clamped, never read as the default). */
@@ -1,4 +1,14 @@
1
1
  import { AUTO_MODE_DEFAULT_FAILURE_THRESHOLD, AUTO_MODE_DEFAULT_TIMEOUT_MS, AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS, AUTO_MODE_DENIAL_LIMIT_DEFAULTS } from "./auto-mode-defaults.js";
2
+ export const AUTO_MODE_UNAVAILABLE_CAUSES = ["error", "timeout", "breaker_open"];
3
+ const AUTO_MODE_UNAVAILABLE_CAUSE_SET = new Set(AUTO_MODE_UNAVAILABLE_CAUSES);
4
+ export function isAutoModeUnavailableCause(v) {
5
+ return AUTO_MODE_UNAVAILABLE_CAUSE_SET.has(v);
6
+ }
7
+ export const AUTO_MODE_BREAKER_CAUSES = ["error", "timeout", "parse_error"];
8
+ const AUTO_MODE_BREAKER_CAUSE_SET = new Set(AUTO_MODE_BREAKER_CAUSES);
9
+ export function isAutoModeBreakerCause(v) {
10
+ return AUTO_MODE_BREAKER_CAUSE_SET.has(v);
11
+ }
2
12
  export function parseAutoModeResponse(text) {
3
13
  const t = text
4
14
  .replace(/<thinking>[\s\S]*?<\/thinking>/g, "")
@@ -117,6 +127,27 @@ class AutoModeTimeout extends Error {
117
127
  super("auto-mode classify timeout");
118
128
  }
119
129
  }
130
+ export class AutoModeBreakerLedger {
131
+ cap;
132
+ trips = new Map();
133
+ constructor(cap = 1024) {
134
+ this.cap = cap;
135
+ }
136
+ record(sessionId, trip) {
137
+ if (this.trips.has(sessionId))
138
+ this.trips.delete(sessionId);
139
+ this.trips.set(sessionId, trip);
140
+ while (this.trips.size > this.cap) {
141
+ const oldest = this.trips.keys().next().value;
142
+ if (oldest === undefined)
143
+ break;
144
+ this.trips.delete(oldest);
145
+ }
146
+ }
147
+ lastTrip(sessionId) {
148
+ return this.trips.get(sessionId);
149
+ }
150
+ }
120
151
  export function readDenialLimitFallback(v) {
121
152
  if (typeof v !== "object" || v === null)
122
153
  return undefined;
@@ -813,6 +813,14 @@ export type PendingAction = {
813
813
  * Written only when it is a member of the set; a pre-existing row reads absent (unreported).
814
814
  * Display metadata — the resume belts keep reading the gate's own bit. */
815
815
  origin?: import("./ask-origin.js").AskOrigin;
816
+ /** #616 (additive, same precedent) — the PARK twin of `AskRequest.classifierUnavailable`: present ⇔ the
817
+ * auto-mode classifier was consulted on the parked ask and could not run, with the verdict's own cause
818
+ * word ({@link import("./auto-mode.js").AutoModeUnavailableCause}). Written only when the cause is a
819
+ * member of the set; a pre-existing row reads absent. Display metadata (a durable card's "asked because
820
+ * the classifier was unavailable" sentence) — the resume belts never read it. */
821
+ classifierUnavailable?: {
822
+ readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
823
+ };
816
824
  /**
817
825
  * design/80 D-1 §2 (slice 1a.2): the server-minted **opaque** boundInputHash of {@link args} — a
818
826
  * SHA-256 (hex) via {@link import("./canonical-json.js").boundInputHashOf}, computed ONCE here at
@@ -1920,6 +1928,12 @@ export interface CheckpointSummary {
1920
1928
  * value is a member of the closed set ({@link import("./ask-origin.js").isAskOrigin}) — a bad value
1921
1929
  * reads as absent, never as a word. OMITTED when the row carries none (a pre-existing row). */
1922
1930
  origin?: import("./ask-origin.js").AskOrigin;
1931
+ /** The row's `PendingAction.tool_approval.classifierUnavailable` (#616), projected for the same one-call
1932
+ * reason. Echoed ONLY when the stored cause is a member of the closed set
1933
+ * ({@link import("./auto-mode.js").isAutoModeUnavailableCause}); OMITTED when the row carries none. */
1934
+ classifierUnavailable?: {
1935
+ readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
1936
+ };
1923
1937
  /**
1924
1938
  * #438: PRESENCE ONLY — this pending `tool_approval` row's payload carries at least one DIRECTIONAL
1925
1939
  * format control (the {@link import("./tool-policy.js").AskRequest.hasBidiControls} character class;