@sema-agent/core 7.9.2 → 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 (60) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/agents/child-model-seat.d.ts +31 -1
  3. package/dist/agents/child-model-seat.js +12 -0
  4. package/dist/agents/subagent.d.ts +2 -2
  5. package/dist/agents/subagent.js +6 -4
  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/runner/contracts.d.ts +26 -2
  23. package/dist/core/runner/denial-limit-arms.d.ts +14 -3
  24. package/dist/core/runner/denial-limit-arms.js +15 -5
  25. package/dist/core/runner/permission-rule-lanes.d.ts +7 -1
  26. package/dist/core/runner/permission-rule-lanes.js +9 -3
  27. package/dist/core/runner/prepare-caps-and-workflow.d.ts +1 -1
  28. package/dist/core/runner/prepare-caps-and-workflow.js +13 -3
  29. package/dist/core/runner/prepare-gate-stations.d.ts +3 -2
  30. package/dist/core/runner/prepare-gate-stations.js +3 -0
  31. package/dist/core/runner/prepare-policy-chain.js +7 -6
  32. package/dist/core/runner/prepare-wiring-manifest.d.ts +1 -1
  33. package/dist/core/runner/prepare-wiring-manifest.js +8 -1
  34. package/dist/core/runner/runtask.d.ts +33 -32
  35. package/dist/core/runner/runtask.js +63 -33
  36. package/dist/core/runner-deps.d.ts +9 -2
  37. package/dist/core/swappable-deps.d.ts +90 -0
  38. package/dist/core/swappable-deps.js +55 -0
  39. package/dist/core/tool-policy.d.ts +26 -0
  40. package/dist/core/tool-policy.js +5 -1
  41. package/dist/core/wiring-manifest.d.ts +15 -1
  42. package/dist/core/wiring-manifest.js +10 -2
  43. package/dist/core/workflow-journal-store.d.ts +21 -2
  44. package/dist/core/workflow-journal-store.js +1 -1
  45. package/dist/engine/execution-env/node-execution-env.d.ts +2 -0
  46. package/dist/engine/execution-env/node-execution-env.js +2 -1
  47. package/dist/engine/harness/types.d.ts +11 -0
  48. package/dist/index.d.ts +5 -3
  49. package/dist/index.js +5 -3
  50. package/dist/orchestration/run-workflow-tool.d.ts +17 -0
  51. package/dist/orchestration/run-workflow-tool.js +12 -0
  52. package/dist/orchestration/workflow-observe.d.ts +1 -1
  53. package/dist/orchestration/workflow-observe.js +2 -0
  54. package/dist/orchestration/workflow-types.d.ts +37 -2
  55. package/dist/orchestration/workflow-types.js +16 -0
  56. package/dist/orchestration/workflow.d.ts +41 -2
  57. package/dist/orchestration/workflow.js +295 -44
  58. package/dist/stores/file/workflow-journal-store.js +10 -3
  59. package/package.json +1 -1
  60. package/test/export-surface.snapshot.json +51 -5
@@ -5,6 +5,8 @@ import { PRESENT_PLAN_TOOL_NAME } from "../present-plan-tool.js";
5
5
  import { screenGateOutcome } from "../gate-outcome.js";
6
6
  import { directDeny } from "./gate-exit.js";
7
7
  import { emitTrace } from "../trace.js";
8
+ import { deliverEngineNotice } from "../engine-notice.js";
9
+ import { askUnresolvableNotice } from "../ask-unresolvable-notice.js";
8
10
  import { createWriteProtectionCheck } from "../write-protect.js";
9
11
  import { PATH_CONFINABLE_WRITE_TOOLS } from "./session-rule-policy.js";
10
12
  import { toolCallFaceOf } from "../tool-roster.js";
@@ -120,6 +122,7 @@ export function prepareGateStations(input) {
120
122
  shellGated: (e.toolName === "Bash" && shellGatedBash) || (e.toolName === "Monitor" && shellGatedMonitor),
121
123
  ...(autoModeDecider ? { autoMode: { decider: autoModeDecider, ...(autoModeDenialTracking !== undefined ? { denialTracking: autoModeDenialTracking } : {}) } } : {}),
122
124
  onHeadlessDenialLimit: stopForDenialLimit,
125
+ onAskUnresolvable: (info) => deliverEngineNotice(deps.onNotice, askUnresolvableNotice({ sessionId, ...info })),
123
126
  ...(permissionRuleLane
124
127
  ? {
125
128
  persistedRules: {
@@ -252,6 +252,7 @@ export async function preparePolicyChain(input) {
252
252
  sessionId,
253
253
  root: taskRootFinal,
254
254
  liveCwd: () => handsCwdRef?.current,
255
+ home: executionEnv.homeDir,
255
256
  questionToolMounted,
256
257
  questionToolName: ASK_USER_QUESTION_TOOL_NAME,
257
258
  onRevisionDefect: (message) => deps.onError?.(new Error(message), { phase: "config", sessionId }),
@@ -299,7 +300,7 @@ export async function preparePolicyChain(input) {
299
300
  return { action: "allow", updatedInput: editArgs };
300
301
  if (judged.kind === "deny")
301
302
  return judged.result;
302
- const { ask: editAsk, fallback: editFallback, mintedHere: editMintedHere, origin: editOrigin } = judged;
303
+ const { ask: editAsk, fallback: editFallback, mintedHere: editMintedHere } = judged;
303
304
  re = editAsk;
304
305
  const rr = await resolveAsk({
305
306
  toolName: creq.toolName,
@@ -311,7 +312,7 @@ export async function preparePolicyChain(input) {
311
312
  ...riskAxesOf(creq.toolName),
312
313
  ...(re.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
313
314
  ...(re.persistedRuleShadowed !== undefined ? { persistedRuleShadowed: re.persistedRuleShadowed } : {}),
314
- ...inheritedAskCarry(editOrigin, editFallback, isLiveApproverSeat(onAskOf), ancestorTracker),
315
+ ...inheritedAskCarry(judged, isLiveApproverSeat(onAskOf), ancestorTracker),
315
316
  ruleEvidence: inheritedAskEvidence,
316
317
  }, onAskOf, csignal ?? abortController.signal, lateAskSettlementObserver({ toolName: creq.toolName, toolCallId: creq.toolCallId, sessionId, runId, ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}), onNotice: deps.onNotice, onError: deps.onError }));
317
318
  settleDenialLimitFallback({ fallback: editFallback, mintedHere: editMintedHere, tracker: ancestorTracker, resolved: rr, headless: headlessDenyAtRecheck, stop: stopForDenialLimit, toolName: creq.toolName, toolCallId: creq.toolCallId });
@@ -372,7 +373,7 @@ export async function preparePolicyChain(input) {
372
373
  return { action: "allow" };
373
374
  if (judged.kind === "deny")
374
375
  return judged.result;
375
- const { ask: inheritedAsk, fallback, mintedHere, origin: inheritedOrigin } = judged;
376
+ const { ask: inheritedAsk, fallback, mintedHere } = judged;
376
377
  if (fallback === undefined && sandboxAdmissionArmed && policyAskClassOf(pc.policy) === "sandbox_local" && !sandboxBoundaryCapable(creq.toolName)) {
377
378
  recordAncestorSandboxAdmission(creq.toolCallId, creq.toolName);
378
379
  return { action: "allow" };
@@ -389,7 +390,7 @@ export async function preparePolicyChain(input) {
389
390
  ...riskAxesOf(creq.toolName),
390
391
  ...(inheritedAsk.action === "ask" && inheritedAsk.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
391
392
  ...(inheritedAsk.action === "ask" && inheritedAsk.persistedRuleShadowed !== undefined ? { persistedRuleShadowed: inheritedAsk.persistedRuleShadowed } : {}),
392
- ...inheritedAskCarry(inheritedOrigin, fallback, isLiveApproverSeat(pc.onAsk), pc.autoMode?.denialTracking),
393
+ ...inheritedAskCarry(judged, isLiveApproverSeat(pc.onAsk), pc.autoMode?.denialTracking),
393
394
  ruleEvidence: inheritedAskEvidence,
394
395
  }, pc.onAsk, csignal ?? abortController.signal, lateAskSettlementObserver({ toolName: creq.toolName, toolCallId: creq.toolCallId, sessionId, runId, ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}), onNotice: deps.onNotice, onError: deps.onError }));
395
396
  const askWaitMs = Math.max(0, now() - askT0);
@@ -452,7 +453,7 @@ export async function preparePolicyChain(input) {
452
453
  return decision.updatedInput !== undefined ? { action: "allow", updatedInput: decision.updatedInput } : { action: "allow" };
453
454
  if (judged.kind === "deny")
454
455
  return judged.result;
455
- const { ask: inheritedAsk, fallback, mintedHere, origin: inheritedOrigin } = judged;
456
+ const { ask: inheritedAsk, fallback, mintedHere } = judged;
456
457
  if (fallback === undefined && sandboxAdmissionArmed && policyAskClassOf(pc.policy) === "sandbox_local" && !sandboxBoundaryCapable(creq.toolName)) {
457
458
  recordAncestorSandboxAdmission(creq.toolCallId, creq.toolName);
458
459
  return decision.updatedInput !== undefined ? { action: "allow", updatedInput: decision.updatedInput } : { action: "allow" };
@@ -468,7 +469,7 @@ export async function preparePolicyChain(input) {
468
469
  ...riskAxesOf(creq.toolName),
469
470
  ...(inheritedAsk.action === "ask" && inheritedAsk.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
470
471
  ...(inheritedAsk.action === "ask" && inheritedAsk.persistedRuleShadowed !== undefined ? { persistedRuleShadowed: inheritedAsk.persistedRuleShadowed } : {}),
471
- ...inheritedAskCarry(inheritedOrigin, fallback, isLiveApproverSeat(pc.onAsk), pc.autoMode?.denialTracking),
472
+ ...inheritedAskCarry(judged, isLiveApproverSeat(pc.onAsk), pc.autoMode?.denialTracking),
472
473
  ruleEvidence: inheritedAskEvidence,
473
474
  }, pc.onAsk, csignal ?? abortController.signal, lateAskSettlementObserver({ toolName: creq.toolName, toolCallId: creq.toolCallId, sessionId, runId, ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}), onNotice: deps.onNotice, onError: deps.onError }));
474
475
  const askWaitMs = Math.max(0, now() - askT0);
@@ -46,7 +46,7 @@ export interface PrepareWiringManifestInput {
46
46
  deps: RunnerDeps;
47
47
  /** borrowed-readonly — the trusted spawn-side channel: `questionFaceStripped`, `isDelegatedChild`, `onTaskNotification`,
48
48
  * `peerSelfRef`, `explicitAgentName` (the drain), `insideFork` / `agentName` / `parentToolCallId` (the identity). */
49
- internals: Pick<RunInternals, "questionFaceStripped" | "isDelegatedChild" | "onTaskNotification" | "peerSelfRef" | "explicitAgentName" | "insideFork" | "agentName" | "parentToolCallId"> | undefined;
49
+ internals: Pick<RunInternals, "questionFaceStripped" | "isDelegatedChild" | "onTaskNotification" | "peerSelfRef" | "explicitAgentName" | "insideFork" | "agentName" | "parentToolCallId" | "autoModeBreakerLedger"> | undefined;
50
50
  /** borrowed-readonly — the resume leg, or undefined (the manifest's leg derivation reads presence only). */
51
51
  resume: Pick<PrepareResume, "seed"> | undefined;
52
52
  /** borrowed-readonly — the acquired session id. */
@@ -199,7 +199,14 @@ export function prepareWiringManifest(input) {
199
199
  memoryAdmissionWired: deps.memoryScopeAdmission !== undefined,
200
200
  retentionPolicyWired: deps.retentionPolicy !== undefined,
201
201
  ...(modelGateManifest !== undefined ? { modelGate: modelGateManifest } : {}),
202
- autoMode: { armed: autoModeArmReason === "armed", reason: autoModeArmReason },
202
+ autoMode: {
203
+ armed: autoModeArmReason === "armed",
204
+ reason: autoModeArmReason,
205
+ ...(() => {
206
+ const trip = internals?.autoModeBreakerLedger?.lastTrip(sessionId);
207
+ return trip !== undefined ? { breaker: trip } : {};
208
+ })(),
209
+ },
203
210
  mcp: mcpManifestEntries(lockedPreflight.mcp, mcp.statuses),
204
211
  tools: toolRoster,
205
212
  });
@@ -1,9 +1,10 @@
1
1
  import { type Model } from "../../internal/llm.js";
2
2
  import { type CheckpointToken, type ResumeOutcome } from "../checkpoint-store.js";
3
3
  import { type TaskOutcome } from "../task-outcome.js";
4
+ import { type SwappableDeps } from "../swappable-deps.js";
4
5
  import { type SideQuerySpec, type SideQueryResult } from "../side-query.js";
5
6
  import type { SessionStore } from "../session.js";
6
- import type { AgentDefinition, ModelRef, ModelRoles, RunnerDeps, TaskEvent, TaskResult, TaskSpec, TaskStream } from "../types.js";
7
+ import type { AgentDefinition, ModelRoles, RunnerDeps, TaskEvent, TaskResult, TaskSpec, TaskStream } from "../types.js";
7
8
  import type { ResumeRun, ResumeTaskConfig, RunInternals, RunnerSelfSeat } from "./contracts.js";
8
9
  export type { ResumeTaskConfig } from "./contracts.js";
9
10
  /**
@@ -20,6 +21,9 @@ export declare class Runner implements RunnerSelfSeat {
20
21
  * authority: a durable resume's checkpoint seed wins on its leg, and a miss (cross-process,
21
22
  * evicted) falls back to the transcript-replay rung. See {@link SessionReadFileStates}. */
22
23
  private readonly sessionReadStates;
24
+ /** #616 — the per-session breaker read face's record (see {@link RunInternals.autoModeBreakerLedger}). Runner-lived:
25
+ * a trip in one leg is what the session's next leg reports. */
26
+ private readonly autoModeBreakerLedger;
23
27
  /** task notifications born BETWEEN turns (run torn down / harness already idle), parked
24
28
  * per session and drained into the session's next run at its first boundary. Runner-lived (outlives any
25
29
  * single run, like the registry handles that produce into it); bounded + drop-disclosing, see
@@ -57,7 +61,7 @@ export declare class Runner implements RunnerSelfSeat {
57
61
  private readonly locallyClaimedTokens;
58
62
  private static readonly PARENT_CONSTRAINT_REGISTRY_CAP;
59
63
  /** The lanes' LIVE view of `deps` ({@link RunnerDepsSeat}): a getter, never a captured object, because
60
- * `swapModels` replaces the object and a rung reading after its own await must see the replacement. */
64
+ * `swapDeps` replaces the object and a rung reading after its own await must see the replacement. */
61
65
  private readonly depsSeat;
62
66
  constructor(deps: RunnerDeps);
63
67
  /** design/73 §1 v1 — fire `RunnerDeps.onTaskOutcome` through the single swallow-guarded chokepoint
@@ -88,38 +92,35 @@ export declare class Runner implements RunnerSelfSeat {
88
92
  /** Acquire the lock for a sessionId; returns a release fn. New (undefined) sessions need no lock. */
89
93
  private acquireSessionLock;
90
94
  /**
91
- * Hot-swap the model catalog (and optionally the tier bindings) without restarting the process or
92
- * rebuilding the Runner the deployment seat that makes "switching models" a zero-restart
93
- * operation, and the ONLY sanctioned generation change.
95
+ * Hot-swap the deployment seats a running Runner may change without a restart — the ONE door for every
96
+ * hot-swappable seat ({@link SWAPPABLE_DEP_SEATS}: the model catalog generation `models` + its `tiers`
97
+ * bindings, and the deployment's read-face declaration `readFace`), and the ONLY sanctioned way to
98
+ * change any of them after construction. It replaces the former `swapModels` verb (retired, no alias):
99
+ * one verb per seat restated the same laws with a list of verbs as the only record of what is swappable.
94
100
  *
95
- * (Precisely: the constructor runs {@link expandTiers} once and keeps a private expanded copy **only
96
- * when `RunnerDeps.tiers` is configured** that is the arm where mutating the shared table after
97
- * construction provably never took effect. A tiers-less deployment's Runner holds the caller's own
98
- * `models` object BY REFERENCE, so mutating it after construction does leak through; that is an
99
- * accident of the expansion being a no-op, not a contract, and this verb is still the supported way
100
- * to change a generation it is what validates, announces, and computes the pairing disclosure.)
101
- *
102
- * Semantics:
103
- * - **Atomic**: the candidate catalog is tier-expanded and validated FIRST (an illegal tier
104
- * binding throws exactly like the constructor's boot error) on any throw the current
105
- * generation stays in force, untouched.
106
- * - **In-flight tasks keep their generation** (natural snapshot): a running task resolved its
107
- * `Model` object at prepare time and holds that reference; the swap changes what FUTURE
108
- * prepares (and auto-sourced agents' string refs {@link agentCatalog} copies per read)
109
- * resolve. Same-name redirects therefore never re-route or re-price a task mid-run; the
110
- * divergence window is exactly the in-flight tasks' lifetime, by design.
111
- * - `tiers` omitted the current tier bindings are kept (and re-applied over the new models);
112
- * explicitly passed (including `undefined`) replaced.
113
- * - Success is announced via `config.models_swapped` (models/tiers counts key material only,
114
- * never the catalog itself). A swap may additionally emit the advisory
115
- * `route.base_url_changed_key_unchanged` notice (deliberately conservative see its entry in
116
- * the notice directory) for same-name entries whose URL moved while the Model-visible
117
- * credential fingerprint did not.
101
+ * The laws, stated once (see the vocabulary module for the seat-by-seat meaning):
102
+ * - **Screened first, written once atomic.** The candidate is screened whole
103
+ * ({@link screenSwappableDeps}: the key set is the closed one, at least one seat, `tiers` rides with
104
+ * `models`, each seat's own value gate `readFace` through the very `assertReadFaceValue` every
105
+ * prepare door applies) and the catalog is tier-expanded on the candidate (an illegal binding throws
106
+ * exactly like the constructor's boot error) BEFORE the deps object is replaced. On any throw the
107
+ * current deps stay in force, byte-identical.
108
+ * - **In-flight legs keep their snapshot.** A running task read `this.deps` at its prepare and holds
109
+ * that object: its `Model`, its resolved read face, everything. The swap changes what FUTURE prepares
110
+ * (and auto-sourced agents' string refs {@link agentCatalog} copies per read) resolve. Same-name
111
+ * redirects never re-route or re-price a task mid-run; a read-face change never re-fences a mounted
112
+ * band; the divergence window is exactly the in-flight legs' lifetime, by design.
113
+ * - **Presence is the instruction.** A key present on the candidate is replaced (an explicit
114
+ * `undefined` clears an optional seat: `tiers: undefined` no bindings, `readFace: undefined` ⇒ the
115
+ * engine default); a key absent is kept. `tiers` omitted beside `models` the current bindings are
116
+ * re-applied over the new catalog.
117
+ * - **Announced per seat.** `config.models_swapped` (models/tiers counts key material only, never the
118
+ * catalog itself; plus the advisory `route.base_url_changed_key_unchanged` for same-name entries
119
+ * whose URL moved while the Model-visible credential fingerprint did not) and
120
+ * `config.read_face_swapped` (`{ from, to, occurrenceId }`, the seat's previous and new value, `null`
121
+ * = no declaration)the table {@link SWAP_SEAT_NOTICE} binds every seat to its line.
118
122
  */
119
- swapModels(next: {
120
- models: Record<string, Model>;
121
- tiers?: Record<string, ModelRef>;
122
- }): void;
123
+ swapDeps(next: SwappableDeps): void;
123
124
  /** one-shot brain-routed utility query (see {@link runSideQuery} for the full contract):
124
125
  * preserves system/multi-turn messages/tool DEFINITIONS, routes through the deployment's brain with
125
126
  * the same model resolution as tasks, returns real usage/model. No session, no tool execution, no
@@ -23,6 +23,8 @@ import { resolveReasoning } from "../../brain/reasoning.js";
23
23
  import { adjudicateDerivedRoute, authCarrierFingerprint, fallbackToPrimaryNotice, normalizeBaseUrl, sameRouteIdentity } from "../../brain/route-adjudicator.js";
24
24
  import { runWithBrainTelemetry, runWithReasoningWireFacts, runWithStatusSink } from "../../brain/status-sink.js";
25
25
  import { expandTiers, resolveModel, resolveTaskModel } from "../roles.js";
26
+ import { screenSwappableDeps } from "../swappable-deps.js";
27
+ import { AutoModeBreakerLedger } from "../auto-mode.js";
26
28
  import { runSideQuery } from "../side-query.js";
27
29
  import { generatePromptSuggestions } from "./prompt-suggestions.js";
28
30
  import { PushQueue } from "../push-queue.js";
@@ -82,6 +84,7 @@ export class Runner {
82
84
  sessions;
83
85
  sessionLocks = new Map();
84
86
  sessionReadStates = new SessionReadFileStates();
87
+ autoModeBreakerLedger = new AutoModeBreakerLedger();
85
88
  pendingSessionNotifications = new PendingSessionNotifications();
86
89
  parentConstraintRegistry = new Map();
87
90
  suspendedEnvReaps = new Map();
@@ -213,41 +216,67 @@ export class Runner {
213
216
  });
214
217
  };
215
218
  }
216
- swapModels(next) {
217
- if (next.models === null || typeof next.models !== "object" || Array.isArray(next.models)) {
218
- throw new Error(`swapModels: models must be a plain Record<string, Model> (got ${next.models === null ? "null" : Array.isArray(next.models) ? "array" : typeof next.models}) the current generation stays in force`);
219
- }
220
- const tiers = Object.hasOwn(next, "tiers") ? next.tiers : this.deps.tiers;
221
- const expanded = tiers && Object.keys(tiers).length > 0 ? expandTiers({ ...next.models }, tiers) : { ...next.models };
219
+ swapDeps(next) {
220
+ const screened = screenSwappableDeps(next);
221
+ const { seats, candidate } = screened;
222
+ const catalogSwap = seats.includes("models");
223
+ let expanded;
224
+ let tiers;
222
225
  const movedEntries = [];
223
- for (const [name, nextModel] of Object.entries(expanded ?? {})) {
224
- const prior = this.deps.models?.[name];
225
- if (!prior)
226
- continue;
227
- const from = normalizeBaseUrl(prior.baseUrl);
228
- const to = normalizeBaseUrl(nextModel.baseUrl);
229
- if (from !== to && authCarrierFingerprint(prior.headers) === authCarrierFingerprint(nextModel.headers)) {
230
- movedEntries.push({ modelId: name, from, to });
231
- }
232
- }
233
- this.deps = { ...this.deps, models: expanded, ...(tiers !== undefined ? { tiers } : {}) };
234
- if (tiers === undefined)
235
- delete this.deps.tiers;
236
- deliverEngineNotice(this.deps.onNotice, {
237
- code: "config.models_swapped",
238
- message: `model catalog swapped: ${Object.keys(next.models).length} model(s), ${tiers ? Object.keys(tiers).length : 0} tier binding(s); in-flight tasks finish on their resolved models, new tasks resolve against the new catalog`,
239
- detail: { models: Object.keys(next.models).length, tiers: tiers ? Object.keys(tiers).length : 0, occurrenceId: uuidv7() },
240
- });
241
- if (movedEntries.length > 0) {
242
- const RENDER_CAP = 8;
243
- const rendered = movedEntries.slice(0, RENDER_CAP);
226
+ if (catalogSwap) {
227
+ const models = candidate.models;
228
+ tiers = Object.hasOwn(candidate, "tiers") ? candidate.tiers : this.deps.tiers;
229
+ expanded = tiers && Object.keys(tiers).length > 0 ? expandTiers({ ...models }, tiers) : { ...models };
230
+ for (const [name, nextModel] of Object.entries(expanded ?? {})) {
231
+ const prior = this.deps.models?.[name];
232
+ if (!prior)
233
+ continue;
234
+ const from = normalizeBaseUrl(prior.baseUrl);
235
+ const to = normalizeBaseUrl(nextModel.baseUrl);
236
+ if (from !== to && authCarrierFingerprint(prior.headers) === authCarrierFingerprint(nextModel.headers)) {
237
+ movedEntries.push({ modelId: name, from, to });
238
+ }
239
+ }
240
+ }
241
+ const faceSwap = seats.includes("readFace");
242
+ const priorFace = this.deps.readFace;
243
+ const written = {
244
+ ...this.deps,
245
+ ...(expanded !== undefined ? { models: expanded } : {}),
246
+ ...(catalogSwap && tiers !== undefined ? { tiers } : {}),
247
+ ...(faceSwap && candidate.readFace !== undefined ? { readFace: candidate.readFace } : {}),
248
+ };
249
+ if (catalogSwap && tiers === undefined)
250
+ delete written.tiers;
251
+ if (faceSwap && candidate.readFace === undefined)
252
+ delete written.readFace;
253
+ this.deps = written;
254
+ if (catalogSwap) {
255
+ const modelCount = Object.keys(candidate.models).length;
256
+ deliverEngineNotice(this.deps.onNotice, {
257
+ code: "config.models_swapped",
258
+ message: `model catalog swapped: ${modelCount} model(s), ${tiers ? Object.keys(tiers).length : 0} tier binding(s); in-flight tasks finish on their resolved models, new tasks resolve against the new catalog`,
259
+ detail: { models: modelCount, tiers: tiers ? Object.keys(tiers).length : 0, occurrenceId: uuidv7() },
260
+ });
261
+ if (movedEntries.length > 0) {
262
+ const RENDER_CAP = 8;
263
+ const rendered = movedEntries.slice(0, RENDER_CAP);
264
+ deliverEngineNotice(this.deps.onNotice, {
265
+ code: "route.base_url_changed_key_unchanged",
266
+ message: `model catalog swap moved ${movedEntries.length} entry/entries to a new baseUrl while the Model-visible credential half stayed unchanged: ` +
267
+ rendered.map((e) => `"${e.modelId}" ${e.from || "(config root)"} → ${e.to || "(config root)"}`).join("; ") +
268
+ (movedEntries.length > rendered.length ? `; +${movedEntries.length - rendered.length} more` : "") +
269
+ " — if the provider changed (not just its domain), update the credential reference in the same step",
270
+ detail: { entries: rendered, total: movedEntries.length, occurrenceId: uuidv7() },
271
+ });
272
+ }
273
+ }
274
+ if (faceSwap) {
275
+ const to = candidate.readFace ?? null;
244
276
  deliverEngineNotice(this.deps.onNotice, {
245
- code: "route.base_url_changed_key_unchanged",
246
- message: `model catalog swap moved ${movedEntries.length} entry/entries to a new baseUrl while the Model-visible credential half stayed unchanged: ` +
247
- rendered.map((e) => `"${e.modelId}" ${e.from || "(config root)"} → ${e.to || "(config root)"}`).join("; ") +
248
- (movedEntries.length > rendered.length ? `; +${movedEntries.length - rendered.length} more` : "") +
249
- " — if the provider changed (not just its domain), update the credential reference in the same step",
250
- detail: { entries: rendered, total: movedEntries.length, occurrenceId: uuidv7() },
277
+ code: "config.read_face_swapped",
278
+ message: `deployment read face swapped: ${priorFace ?? "(no declaration)"} ${to ?? "(no declaration)"}; in-flight legs keep the face they prepared under, new legs resolve against the new declaration`,
279
+ detail: { from: priorFace ?? null, to, occurrenceId: uuidv7() },
251
280
  });
252
281
  }
253
282
  }
@@ -962,6 +991,7 @@ export class Runner {
962
991
  peerInboundChainRef,
963
992
  detachHub,
964
993
  sessionReadStates: this.sessionReadStates,
994
+ autoModeBreakerLedger: this.autoModeBreakerLedger,
965
995
  onTaskNotification: (notification, opts) => {
966
996
  try {
967
997
  upstreamTaskNotification?.(notification, opts);
@@ -400,6 +400,11 @@ export interface RunnerDeps {
400
400
  * may not. Beside a read-only (verifier) mount this seat's "open" silently CLAMPS to roots (a
401
401
  * deployment default cannot override a load-bearing containment wall) — TaskSpec.readFace's own
402
402
  * "open" there is the genuine per-task contradiction, and that one still refuses loudly (#123).
403
+ *
404
+ * A LIVE seat: `Runner.swapDeps({ readFace })` replaces it without a restart (the same natural
405
+ * snapshot the model catalog has — a leg reads the seat at its prepare and keeps that reading; every
406
+ * later prepare reads the new declaration; announced as `config.read_face_swapped`). A host whose
407
+ * declaration arrives late (a governance source read after boot) swaps it in rather than restarting.
403
408
  */
404
409
  readFace?: import("../tools/fs/read-face.js").ReadFace;
405
410
  /**
@@ -686,10 +691,12 @@ export interface RunnerDeps {
686
691
  timeoutMs?: number;
687
692
  /** Consecutive-failure threshold opening the one-way session breaker (default 3). */
688
693
  failureThreshold?: number;
689
- /** Fired ONCE when the session breaker opens (the "退回非 auto" alarm — surface it to the operator). */
694
+ /** Fired ONCE when the session breaker opens (the "退回非 auto" alarm — surface it to the operator). The engine
695
+ * records the trip on the session's breaker read face (`WiringManifest.autoMode.breaker`, #616) BEFORE calling
696
+ * this hook, so a deployment that wires nothing here still gets the read face. */
690
697
  onBreakerOpen?: (info: {
691
698
  consecutiveFailures: number;
692
- lastCause: string;
699
+ lastCause: import("./auto-mode.js").AutoModeBreakerCause;
693
700
  }) => void;
694
701
  /**
695
702
  * The classifier DENIAL LIMIT (CC 2.1.250 `FO`/`AKe`): a run whose classifier keeps blocking falls
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The deployment seats a Runner may HOT-SWAP after construction — the vocabulary behind the ONE door,
3
+ * `Runner.swapDeps` (runtask.ts).
4
+ *
5
+ * WHY ONE DOOR. Each hot-swappable seat used to be a verb of its own (`swapModels` was the first), and a
6
+ * verb per seat restates the same three laws every time — validate the candidate BEFORE anything is
7
+ * written, write the deps object ONCE (a swap is atomic: a refused candidate leaves the current deps
8
+ * byte-identical), announce the change under a notice code — while the question "which seats may be
9
+ * swapped at all" was answered by the list of verbs, i.e. by nothing a compiler could see. With one door
10
+ * the laws are stated once, the swappable set is THIS closed list, and every seat carries its rows in
11
+ * the tables beside it: the compiler refuses a member without its rows ({@link AssertAllKeysHandled}),
12
+ * the door refuses a key outside the set at run time (never a silent ignore — a hot-config driver
13
+ * handing `{ brain }` must learn that the brain is not a swappable seat, not watch nothing happen).
14
+ *
15
+ * WHAT A SWAP MEANS — the natural snapshot, one sentence for every seat: a running task reads the deps
16
+ * object it was handed at its prepare and holds that reference; a swap replaces the Runner's object, so
17
+ * every LATER prepare reads the new seat and every in-flight leg keeps the value it prepared under.
18
+ * Nothing is re-routed, re-priced or re-fenced mid-run; the divergence window is exactly the in-flight
19
+ * legs' lifetime, by design (the `swapModels` precedent, now the rule for the whole set).
20
+ *
21
+ * KEY PRESENCE IS THE INSTRUCTION: a key present on the candidate is REPLACED (an explicit `undefined`
22
+ * CLEARS an optional seat back to its engine default — `readFace: undefined` ⇒ the deployment declares
23
+ * no read face, `tiers: undefined` ⇒ no tier bindings); a key absent is KEPT. `Object.hasOwn`, never
24
+ * truthiness, so the two spellings cannot be confused. An EMPTY candidate is refused: a swap that names
25
+ * no seat is a caller defect, not a no-op.
26
+ */
27
+ import type { AssertAllKeysHandled } from "./ask-origin.js";
28
+ import type { RunnerDeps } from "./runner-deps.js";
29
+ /**
30
+ * The closed set of hot-swappable deployment seats.
31
+ * - `models` — the model catalog generation (the same-name redirect, re-pricing and retirement rules
32
+ * of the former `swapModels`: a string ref resolves against the new catalog at the next
33
+ * prepare; a `Model` object a leg already holds is untouched).
34
+ * - `tiers` — the tier bindings, a BINDING OVER a catalog: it may only be swapped together with
35
+ * `models` (the door refuses `tiers` alone — re-expanding bindings over an already
36
+ * expanded catalog would keep the retired tier words as ordinary keys, which is exactly
37
+ * the stale-generation shape the swap exists to end). Omitted beside `models` ⇒ the
38
+ * current bindings are re-applied over the new catalog; present (including `undefined`)
39
+ * ⇒ replaced.
40
+ * - `readFace` — the deployment's read-face declaration ({@link RunnerDeps.readFace}); screened by the
41
+ * same value gate every prepare applies (`assertReadFaceValue`: `"open"` | `"roots"`
42
+ * | absent, anything else refused loudly). The RESOLVED face of a leg is still the
43
+ * resolver's business (task seat, governance, the read-only mount); the swap changes the
44
+ * deployment seat that resolution reads.
45
+ */
46
+ export declare const SWAPPABLE_DEP_SEATS: readonly ["models", "tiers", "readFace"];
47
+ export type SwappableDepSeat = (typeof SWAPPABLE_DEP_SEATS)[number];
48
+ /** The candidate a swap takes: the swappable seats of {@link RunnerDeps}, each optional (presence = instruction). */
49
+ export type SwappableDeps = Pick<RunnerDeps, SwappableDepSeat>;
50
+ /** Whether a key names a swappable seat. */
51
+ export declare function isSwappableDepSeat(k: unknown): k is SwappableDepSeat;
52
+ /**
53
+ * Which notice a seat's successful swap is announced under — the disposition table over the set (a seat
54
+ * with no row does not compile: {@link SwapNoticeTableCoversEverySeat}). `models` and `tiers` share the
55
+ * catalog-generation line (one swap, one line, both counts); the read face has its own.
56
+ */
57
+ export declare const SWAP_SEAT_NOTICE: {
58
+ readonly models: "config.models_swapped";
59
+ readonly tiers: "config.models_swapped";
60
+ readonly readFace: "config.read_face_swapped";
61
+ };
62
+ /** The fence over the notice table: `never` while every seat has a row. */
63
+ export type SwapNoticeTableCoversEverySeat = AssertAllKeysHandled<Exclude<SwappableDepSeat, keyof typeof SWAP_SEAT_NOTICE>>;
64
+ /** What the screen hands back: the seats present (set order) and the ONE read of each present seat's value,
65
+ * copied at screen time — the door writes from THIS object and never re-reads the caller's. */
66
+ export interface ScreenedSwappableDeps {
67
+ readonly seats: readonly SwappableDepSeat[];
68
+ /** A fresh plain object: the screened value of every present seat (`models`/`tiers` shallow-copied,
69
+ * `readFace` the screened word; an explicit `undefined` is kept as a PRESENT own key so the door can
70
+ * tell "clear this seat" from "not named"). */
71
+ readonly candidate: SwappableDeps;
72
+ }
73
+ /**
74
+ * Screen a swap candidate BEFORE anything is written: the object shape, the key set (every own key must
75
+ * be a member of {@link SWAPPABLE_DEP_SEATS}; at least one), the `tiers`-rides-with-`models` rule, and
76
+ * each present seat's own value gate. Returns the seats present (set order) and a SNAPSHOT of their values
77
+ * — the one read. Throws on the first defect; the caller has written nothing yet, so the current deps stay
78
+ * in force byte-identical.
79
+ *
80
+ * ONE READ, BY CONSTRUCTION: every seat value is read off the caller's object exactly once, here, and the
81
+ * screened copy is what the door writes and announces. A getter-backed or concurrently-mutated candidate
82
+ * therefore cannot present a legal value to the screen and a different one to the write (or to the
83
+ * announcement, which runs AFTER the write — a throw there would have left a half-announced swap in
84
+ * force). The prepare doors read their deps seats the same way.
85
+ *
86
+ * The per-seat gate is the SAME one the seat's prepare-time reader applies (`readFace` ⇒
87
+ * `assertReadFaceValue` with the deployment-seat name a prepare door uses — one value, one loudness, one
88
+ * seat name on every leg), so a value the door admits is a value every later prepare admits.
89
+ */
90
+ export declare function screenSwappableDeps(next: unknown): ScreenedSwappableDeps;
@@ -0,0 +1,55 @@
1
+ import { assertReadFaceValue } from "../tools/fs/read-face.js";
2
+ export const SWAPPABLE_DEP_SEATS = ["models", "tiers", "readFace"];
3
+ const SWAPPABLE_DEP_SEAT_SET = new Set(SWAPPABLE_DEP_SEATS);
4
+ export function isSwappableDepSeat(k) {
5
+ return SWAPPABLE_DEP_SEAT_SET.has(k);
6
+ }
7
+ export const SWAP_SEAT_NOTICE = {
8
+ models: "config.models_swapped",
9
+ tiers: "config.models_swapped",
10
+ readFace: "config.read_face_swapped",
11
+ };
12
+ export function screenSwappableDeps(next) {
13
+ if (next === null || typeof next !== "object" || Array.isArray(next)) {
14
+ throw new Error(`swapDeps: the candidate must be a plain object naming the seats to swap (got ${next === null ? "null" : Array.isArray(next) ? "array" : typeof next}) — the current deps stay in force`);
15
+ }
16
+ const keys = Object.keys(next);
17
+ const unknown = keys.filter((k) => !isSwappableDepSeat(k));
18
+ if (unknown.length > 0) {
19
+ throw new Error(`swapDeps: ${unknown.map((k) => JSON.stringify(k)).join(", ")} is not a hot-swappable seat (the closed set is ${SWAPPABLE_DEP_SEATS.join(" | ")}) — the current deps stay in force`);
20
+ }
21
+ const present = SWAPPABLE_DEP_SEATS.filter((seat) => Object.hasOwn(next, seat));
22
+ if (present.length === 0) {
23
+ throw new Error(`swapDeps: the candidate names no seat (an empty swap is a caller defect, not a no-op) — the current deps stay in force`);
24
+ }
25
+ const raw = next;
26
+ const candidate = {};
27
+ for (const seat of present) {
28
+ const v = raw[seat];
29
+ switch (seat) {
30
+ case "models":
31
+ if (v === null || typeof v !== "object" || Array.isArray(v)) {
32
+ throw new Error(`swapDeps: models must be a plain Record<string, Model> (got ${v === null ? "null" : Array.isArray(v) ? "array" : typeof v}) — the current generation stays in force`);
33
+ }
34
+ candidate.models = { ...v };
35
+ break;
36
+ case "tiers":
37
+ if (!present.includes("models")) {
38
+ throw new Error(`swapDeps: tiers is a binding over models — pass the catalog it binds in the same swap (\`{ models, tiers }\`); the current generation stays in force`);
39
+ }
40
+ if (v !== undefined && (v === null || typeof v !== "object" || Array.isArray(v))) {
41
+ throw new Error(`swapDeps: tiers must be a plain Record<string, ModelRef> or undefined (got ${v === null ? "null" : Array.isArray(v) ? "array" : typeof v}) — the current generation stays in force`);
42
+ }
43
+ candidate.tiers = v === undefined ? undefined : { ...v };
44
+ break;
45
+ case "readFace":
46
+ candidate.readFace = assertReadFaceValue(v, "readFace (deployment seat)");
47
+ break;
48
+ default: {
49
+ const _exhaustive = seat;
50
+ throw new Error(`swapDeps: unhandled seat ${String(_exhaustive)}`);
51
+ }
52
+ }
53
+ }
54
+ return { seats: present, candidate: candidate };
55
+ }
@@ -204,6 +204,23 @@ export type PermissionResult = {
204
204
  * same safe direction as `matchedAskRule`); it cannot state a window of its own, because the
205
205
  * route it would be a window for has not been chosen yet. */
206
206
  denialLimitFallback?: import("./auto-mode.js").UnarmedDenialLimitFallback;
207
+ /** #616 (additive): the auto-mode classifier was CONSULTED on this ask and could not run — the ask
208
+ * flows the original chain exactly as it would have (routing, origin, bit and members unchanged),
209
+ * carrying the station FACT beside them so a card can say "asked because the classifier was
210
+ * unavailable (timeout)" instead of reading as ordinary hesitation. `cause` is the verdict's own
211
+ * word ({@link import("./auto-mode.js").AutoModeUnavailableCause}: `error` / `timeout` /
212
+ * `breaker_open`). A FACT, not an origin: CC 2.1.250 marks the same condition as a denial KIND on
213
+ * the outcome (`automode-unavailable`) beside the decision's provenance, never in place of it, and
214
+ * this engine's divergence (the ask reaches a person instead of being denied) does not move the
215
+ * fact onto the origin axis either. ENGINE-STAMPED at the classifier stations (the gate's own and
216
+ * the inherited-lane arms) only when the verdict was `unavailable`; `parse_error` stamps nothing
217
+ * (the classifier ran and answered outside its contract — a different sentence). Display metadata:
218
+ * nothing reads it to decide anything, so a policy that self-declares it can only put its own
219
+ * sentence on its own card. Carried onto the approval request and the durable row by the carry
220
+ * stations. */
221
+ classifierUnavailable?: {
222
+ readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
223
+ };
207
224
  /** #144 disclosure (additive): a persisted allow rule MATCHED this call but could not clear the
208
225
  * ask, because the ask is MANDATED (operator shellGate:"always", or the tool's own
209
226
  * egress/irreversibility marks) rather than a classifier's hesitation — "allow rules silence
@@ -1191,6 +1208,15 @@ export interface AskRequest {
1191
1208
  * that forwards a decision's member here does not compile, which is the point: the window is the
1192
1209
  * route's fact, and forwarding it silently is how a configured window becomes a wait with none. */
1193
1210
  readonly denialLimitFallback?: import("./auto-mode.js").DenialLimitFallback;
1211
+ /** #616 (additive) — present ⇔ the auto-mode classifier was consulted on this ask and could not run (see
1212
+ * the {@link PermissionResult} ask-arm member of the same name): the card's "asked because the
1213
+ * classifier was unavailable" fact, with the verdict's own cause word. Absent on every ask the classifier
1214
+ * answered, was not eligible for, or was not wired for — read presence, never absence. Filled by the
1215
+ * carry stations from the decision, never a caller/worker-settable field; the durable park row carries
1216
+ * the same member (`PendingAction.tool_approval.classifierUnavailable`). */
1217
+ readonly classifierUnavailable?: {
1218
+ readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
1219
+ };
1194
1220
  /** (additive) WHICH AUTHORITY raised this ask — the wire twin of the {@link PermissionResult} ask-arm
1195
1221
  * member of the same name, one word from the closed {@link import("./ask-origin.js").AskOrigin} set.
1196
1222
  * The gate's own mint station copies the engine-stamped word; the three inherited-lane stations
@@ -410,6 +410,7 @@ export function combinePolicies(...policies) {
410
410
  let ruleAskText;
411
411
  let probeMandateSeen = false;
412
412
  let fallbackSeen;
413
+ let classifierUnavailableSeen;
413
414
  for (const p of policies) {
414
415
  const d = refuseOutOfContractDecision(await p.check(current, signal));
415
416
  if (d.action === "deny") {
@@ -440,13 +441,16 @@ export function combinePolicies(...policies) {
440
441
  probeMandateSeen = true;
441
442
  if (d.action === "ask" && d.denialLimitFallback !== undefined && fallbackSeen === undefined)
442
443
  fallbackSeen = d.denialLimitFallback;
444
+ if (d.action === "ask" && d.classifierUnavailable !== undefined && classifierUnavailableSeen === undefined)
445
+ classifierUnavailableSeen = d.classifierUnavailable;
443
446
  }
444
447
  if (asked) {
445
448
  const merged = rewrite?.updatedInput;
446
449
  const withRuleAsk = ruleAskText !== undefined && asked.matchedAskRule === undefined ? { ...asked, matchedAskRule: ruleAskText } : asked;
447
450
  const withMark = probeMandateSeen && withRuleAsk.probeMandated !== true ? { ...withRuleAsk, probeMandated: true } : withRuleAsk;
448
451
  const withFallback = fallbackSeen !== undefined && withMark.denialLimitFallback === undefined ? { ...withMark, denialLimitFallback: fallbackSeen, requiresRealApproval: true } : withMark;
449
- return merged !== undefined ? { ...withFallback, updatedInput: merged } : withFallback;
452
+ const withFact = classifierUnavailableSeen !== undefined && withFallback.classifierUnavailable === undefined ? { ...withFallback, classifierUnavailable: { cause: classifierUnavailableSeen.cause } } : withFallback;
453
+ return merged !== undefined ? { ...withFact, updatedInput: merged } : withFact;
450
454
  }
451
455
  const allowed = rewrite ?? ALLOW;
452
456
  if (settledAllow === undefined)