@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
@@ -1,4 +1,4 @@
1
- import { type AutoModeDecider, type AutoModeDenialLimitOptions, type AutoModeDenialTracker, type DenialLimitFallback, type DenialLimitFallbackFace, type UnarmedDenialLimitFallback } from "../auto-mode.js";
1
+ import { type AutoModeDecider, type AutoModeDenialLimitOptions, type AutoModeDenialTracker, type AutoModeUnavailableCause, type DenialLimitFallback, type DenialLimitFallbackFace, type UnarmedDenialLimitFallback } from "../auto-mode.js";
2
2
  import { type AutoModeArmingRecipe } from "../auto-mode-arming.js";
3
3
  import type { PermissionResult, ResolvedAsk, ToolCallRequest } from "../tool-policy.js";
4
4
  import { type EngineNotice } from "../types.js";
@@ -84,10 +84,14 @@ export type InheritedAsk = Extract<PermissionResult, {
84
84
  }>;
85
85
  /** The members of a surviving ask that ride onto the approval request with a COMPUTED value — what the
86
86
  * four request mint stations (the gate's own and the three inherited-lane ones) spread after the seats
87
- * they spell themselves: the ask's origin word and its denial-limit fallback. */
87
+ * they spell themselves: the ask's origin word, its denial-limit fallback and the classifier-unavailable
88
+ * fact (#616). */
88
89
  export interface AskRequestCarry {
89
90
  origin?: AskOrigin;
90
91
  denialLimitFallback?: DenialLimitFallback;
92
+ classifierUnavailable?: {
93
+ readonly cause: AutoModeUnavailableCause;
94
+ };
91
95
  }
92
96
  /** The GATE's own mint station: both members are read off the surviving decision, which is the gate's
93
97
  * OWN snapshot (its stamp point spreads the policy's object and stamps the word onto the copy), so the
@@ -99,7 +103,9 @@ export declare function gateAskCarry(decision: InheritedAsk, liveApprover: boole
99
103
  * fallback member it captured — each read ONCE there and threaded here, never re-read off the
100
104
  * caller-owned object (a stateful accessor could otherwise answer the card with one value and the
101
105
  * settlement with another; a self-declared `origin` on the ancestor's decision is never read). */
102
- export declare function inheritedAskCarry(origin: AskOrigin, fallback: UnarmedDenialLimitFallback | undefined, liveApprover: boolean, tracker: AutoModeDenialTracker | undefined): AskRequestCarry;
106
+ export declare function inheritedAskCarry(judged: Pick<Extract<InheritedClassifierJudgment<InheritedAsk>, {
107
+ kind: "resolve";
108
+ }>, "origin" | "fallback" | "ask">, liveApprover: boolean, tracker: AutoModeDenialTracker | undefined): AskRequestCarry;
103
109
  /** What {@link judgeInheritedClassifier} hands back to the arm that called it. */
104
110
  export type InheritedClassifierJudgment<A extends InheritedAsk> =
105
111
  /** The frozen classifier allowed — the arm returns its own allow shape (with or without the ask's rewrite). */
@@ -118,6 +124,11 @@ export type InheritedClassifierJudgment<A extends InheritedAsk> =
118
124
  * unchanged-ask branches it is the SAME read the exclusion judged by (a caller-owned decision with a
119
125
  * stateful accessor cannot answer the exclusion with one word and the card with another); on the
120
126
  * bound branch it is derived from the re-spoken ask this station minted (its own object). */
127
+ /** `ask` is the incoming ask unchanged — EXCEPT on an UNAVAILABLE round, where it is an engine-owned copy
128
+ * carrying `classifierUnavailable: { cause }` (#616): the fact rides the ASK because the ask is what the
129
+ * wrapper arms hand back to the gate when the frozen approver answers unavailable (the inherited-unavailable
130
+ * float), and the gate's own park carry reads the fact off the decision it parks — a side member on this
131
+ * judgment would have been lost on that route (codex r1). The request mint reads the same object. */
121
132
  | {
122
133
  kind: "resolve";
123
134
  ask: A;
@@ -60,10 +60,18 @@ function fallbackCarry(fallback, liveApprover, tracker) {
60
60
  return { denialLimitFallback: liveApprover && tracker !== undefined ? tracker.armTimedWindow(fallback) : unarmedWindow(fallback) };
61
61
  }
62
62
  export function gateAskCarry(decision, liveApprover, tracker) {
63
- return { ...(decision.origin !== undefined ? { origin: decision.origin } : {}), ...fallbackCarry(decision.denialLimitFallback, liveApprover, tracker) };
63
+ return {
64
+ ...(decision.origin !== undefined ? { origin: decision.origin } : {}),
65
+ ...fallbackCarry(decision.denialLimitFallback, liveApprover, tracker),
66
+ ...(decision.classifierUnavailable !== undefined ? { classifierUnavailable: { cause: decision.classifierUnavailable.cause } } : {}),
67
+ };
64
68
  }
65
- export function inheritedAskCarry(origin, fallback, liveApprover, tracker) {
66
- return { origin, ...fallbackCarry(fallback, liveApprover, tracker) };
69
+ export function inheritedAskCarry(judged, liveApprover, tracker) {
70
+ return {
71
+ origin: judged.origin,
72
+ ...fallbackCarry(judged.fallback, liveApprover, tracker),
73
+ ...(judged.ask.classifierUnavailable !== undefined ? { classifierUnavailable: { cause: judged.ask.classifierUnavailable.cause } } : {}),
74
+ };
67
75
  }
68
76
  export async function judgeInheritedClassifier(opts) {
69
77
  const { autoMode, ask, req } = opts;
@@ -77,8 +85,10 @@ export async function judgeInheritedClassifier(opts) {
77
85
  autoMode.denialTracking?.recordAllow();
78
86
  return { kind: "allow" };
79
87
  }
80
- if (verdict.kind !== "block")
81
- return { kind: "resolve", ask, fallback: ask.denialLimitFallback, mintedHere: false, origin };
88
+ if (verdict.kind !== "block") {
89
+ const resolved = verdict.kind === "unavailable" ? { ...ask, classifierUnavailable: { cause: verdict.cause } } : ask;
90
+ return { kind: "resolve", ask: resolved, fallback: ask.denialLimitFallback, mintedHere: false, origin };
91
+ }
82
92
  const reason = verdict.reason ? inlineUntrusted(verdict.reason) : "";
83
93
  const category = verdict.category ? inlineUntrusted(verdict.category) : "";
84
94
  const tracked = autoMode.denialTracking?.recordBlock();
@@ -107,7 +107,8 @@ export declare function pathRuleLaneAnswer(table: readonly PersistedRule[], req:
107
107
  root: string | undefined;
108
108
  sessionId: string | undefined;
109
109
  liveCwd: string | undefined;
110
- }): PersistedRuleHit | undefined;
110
+ home: string | undefined;
111
+ }): PersistedRuleHit | PersistedRuleUnreadable | undefined;
111
112
  /** The two lanes prepare hands the gate. Each is `undefined` when its partition is not configured, so
112
113
  * a deployment without one keeps a decision path byte-identical to a build without the feature. */
113
114
  export interface PermissionRuleLanes {
@@ -137,6 +138,11 @@ export declare function createPermissionRuleLanes(cfg: {
137
138
  /** The LIVE tracked working directory (adversarial-review P1): the base a relative cd resolves against
138
139
  * after an earlier observable `cd`; read per call. */
139
140
  liveCwd: () => string | undefined;
141
+ /** #644 — the EXECUTION ENVIRONMENT's home directory (`ExecutionEnv.homeDir`, adapter-declared), the base a
142
+ * `~/` path rule resolves against. `undefined` = the adapter declares none: a `~/` deny/ask row then reads
143
+ * UNREADABLE for every call (a fail-closed ask), never the engine process's home. A declared value that is
144
+ * not an absolute path is refused HERE, loudly (a bad declaration is a wiring defect, not an absence). */
145
+ home: string | undefined;
140
146
  /** True iff the reserved question-tool NAME resolves to the ENGINE's own content-ask tool on this leg. */
141
147
  questionToolMounted: boolean;
142
148
  questionToolName: string;
@@ -1,5 +1,6 @@
1
1
  import { persistedRuleMandateOf } from "../hooks.js";
2
2
  import { adjudicatePersistedPathRules, adjudicatePersistedRules, ruleToolGrammarOf, segmentCoverageOf, suggestRulesForCommand } from "../permission-rule-model.js";
3
+ import { isAbsolutePathForm } from "../../tools/fs/safety.js";
3
4
  import { effectivePathTargetOf } from "../effective-path-target.js";
4
5
  import { declaredPathTargetOf, protectivePathTargetOf } from "../tool-registry.js";
5
6
  import { orgRuleVerdictFor } from "../permission-rule-org.js";
@@ -31,15 +32,20 @@ export function persistedRuleAnswerOf(verdict) {
31
32
  return persistedRuleHitOf(verdict);
32
33
  }
33
34
  export function pathRuleLaneAnswer(table, req, ctx) {
34
- const bases = { ...(ctx.root !== undefined ? { root: ctx.root } : {}), ...(ctx.liveCwd !== undefined ? { cwd: ctx.liveCwd } : {}) };
35
+ const bases = { ...(ctx.root !== undefined ? { root: ctx.root } : {}), ...(ctx.liveCwd !== undefined ? { cwd: ctx.liveCwd } : {}), ...(ctx.home !== undefined ? { home: ctx.home } : {}) };
35
36
  const tighten = effectivePathTargetOf(req, protectivePathTargetOf(req), bases);
36
37
  const allow = effectivePathTargetOf(req, declaredPathTargetOf(req), bases);
37
38
  if (tighten === undefined && allow === undefined)
38
39
  return undefined;
39
- return persistedRuleHitOf(adjudicatePersistedPathRules(table, { tool: req.toolName, cwd: ctx.root, sessionId: ctx.sessionId }, { ...(tighten !== undefined ? { tighten } : {}), ...(allow !== undefined ? { allow } : {}) }, bases));
40
+ return persistedRuleAnswerOf(adjudicatePersistedPathRules(table, { tool: req.toolName, cwd: ctx.root, sessionId: ctx.sessionId }, { ...(tighten !== undefined ? { tighten } : {}), ...(allow !== undefined ? { allow } : {}) }, bases));
40
41
  }
41
42
  export function createPermissionRuleLanes(cfg) {
42
43
  const provider = cfg.provider;
44
+ if (cfg.home !== undefined && !isAbsolutePathForm(cfg.home)) {
45
+ const e = new Error(`the execution environment declares homeDir ${JSON.stringify(cfg.home)}, which is not an absolute path — a \`~/\` permission rule could not be resolved against it; declare an absolute home directory or none`);
46
+ e.code = "config.execution_env_home_dir_invalid";
47
+ throw e;
48
+ }
43
49
  if (cfg.localOwnerDeclared) {
44
50
  if (provider === undefined || !provider.partitions.durable) {
45
51
  throw new Error("RunnerDeps.localOwnerRules is declared but no durable permission-rule partition is wired — there is no bucket for the local owner to hold rules in; refusing rather than running as if the declaration were absent");
@@ -127,7 +133,7 @@ export function createPermissionRuleLanes(cfg) {
127
133
  const table = view.rules;
128
134
  const liveCwd = cfg.liveCwd();
129
135
  if (grammar === "path") {
130
- return pathRuleLaneAnswer(table, req, { root: cfg.root, sessionId: cfg.sessionId, liveCwd });
136
+ return pathRuleLaneAnswer(table, req, { root: cfg.root, sessionId: cfg.sessionId, liveCwd, home: cfg.home });
131
137
  }
132
138
  const command = req.args?.command;
133
139
  if (typeof command !== "string")
@@ -49,7 +49,7 @@ export interface PrepareCapsAndWorkflowInput {
49
49
  deps: RunnerDeps;
50
50
  /** borrowed-readonly — the trusted spawn-side channel; the Workflow mount forwards `rootSessionId`, `placementRoot`,
51
51
  * `onTaskNotification`, `workflowDepth`, and the capture-floor getter reads `memoryCaptureAncestors`. */
52
- internals: Pick<RunInternals, "rootSessionId" | "placementRoot" | "onTaskNotification" | "workflowDepth" | "memoryCaptureAncestors"> | undefined;
52
+ internals: Pick<RunInternals, "rootSessionId" | "placementRoot" | "onTaskNotification" | "workflowDepth" | "memoryCaptureAncestors" | "workflowParkedResume" | "autoModeBreakerLedger"> | undefined;
53
53
  /** borrowed-readonly — the acquired session; the classifier leg rebuilds its transcript window from `buildContext`. */
54
54
  session: Pick<StoredSession, "buildContext">;
55
55
  /** borrowed-readonly — the acquired session id (operator-line context, the refusal codes' phase record). */
@@ -1,6 +1,6 @@
1
1
  import { isSyntheticApiErrorMessage } from "../../internal/harness.js";
2
2
  import { createHash } from "node:crypto";
3
- import { forkGovernanceDenial } from "../../agents/subagent.js";
3
+ import { childThinkingSeat, deploymentSubagentThinking, deploymentSubagentTierModel, forkGovernanceDenial } from "../../agents/subagent.js";
4
4
  import { CROSS_SESSION_CLASSIFIER_RULE } from "../../agents/cross-session-envelope.js";
5
5
  import { createRunWorkflowTool } from "../../orchestration/run-workflow-tool.js";
6
6
  import { isSelfOrchestrationActive } from "../../orchestration/workflow-script-runner.js";
@@ -170,7 +170,10 @@ export async function prepareCapsAndWorkflow(input) {
170
170
  autoModeDecider = createAutoModeDecider({
171
171
  ...(am.timeoutMs !== undefined ? { timeoutMs: am.timeoutMs } : {}),
172
172
  ...(am.failureThreshold !== undefined ? { failureThreshold: am.failureThreshold } : {}),
173
- ...(am.onBreakerOpen !== undefined ? { onBreakerOpen: am.onBreakerOpen } : {}),
173
+ onBreakerOpen: (info) => {
174
+ internals?.autoModeBreakerLedger?.record(sessionId, { openedAtMs: Date.now(), lastCause: info.lastCause, failures: info.consecutiveFailures, runId });
175
+ am.onBreakerOpen?.(info);
176
+ },
174
177
  onClassified: (info) => emitTrace(deps.tracer, () => ({
175
178
  kind: "auto_mode.classified",
176
179
  version: 1,
@@ -243,9 +246,16 @@ export async function prepareCapsAndWorkflow(input) {
243
246
  taskOwner: hostTaskId,
244
247
  limits: deps.workflowLimits,
245
248
  sourceTaskId: hostTaskId,
246
- parentModel: () => harnessRef.current?.getModel(),
249
+ parentModel: () => deploymentSubagentTierModel(runnerSelf.agentCatalog) ?? harnessRef.current?.getModel(),
247
250
  ...(spec.getApiKeyAndHeaders !== undefined ? { parentGetApiKeyAndHeaders: spec.getApiKeyAndHeaders } : {}),
248
251
  ...(frozenOnAsk !== undefined ? { parentOnAsk: frozenOnAsk } : {}),
252
+ ...(spec.durableApproval !== undefined ? { parentDurableApproval: { ...spec.durableApproval } } : {}),
253
+ ...(internals?.workflowParkedResume !== undefined
254
+ ? { parkedResume: (resumeFromRunId) => {
255
+ const mine = internals.workflowParkedResume.filter((d) => d.runId === resumeFromRunId).map(({ runId: _r, ...rest }) => rest);
256
+ return mine.length > 0 ? mine : undefined;
257
+ } }
258
+ : {}),
249
259
  principal: spec.principal,
250
260
  oneShot: spec.oneShot,
251
261
  ...(resolvedInteractionPosture !== undefined ? { parentInteractionPosture: resolvedInteractionPosture } : {}),
@@ -258,7 +268,7 @@ export async function prepareCapsAndWorkflow(input) {
258
268
  autoModeReview: () => (autoModeDecider !== undefined ? { decider: autoModeDecider } : undefined),
259
269
  workflowDepth: internals?.workflowDepth,
260
270
  parentCwd: taskRootFinal,
261
- parentThinking: () => harnessRef.current?.getThinkingLevel() ?? thinking,
271
+ parentThinking: () => childThinkingSeat(undefined, deploymentSubagentThinking(runnerSelf.agentCatalog?.roles), harnessRef.current?.getThinkingLevel() ?? thinking).thinking,
262
272
  parentReadFace: () => carrierReadFace(),
263
273
  parentReadDenyPatterns: () => {
264
274
  const readDenyAdditionsNormalized = input.readDenyAdditionsNormalized();
@@ -49,8 +49,9 @@ export interface PrepareGateStationsInput {
49
49
  * first-party tool's declared effect (never overrides a scanned one); the plan-mode deny reads it per call. */
50
50
  toolEffects: Map<string, ToolEffect>;
51
51
  /** borrowed-readonly — the deployment seats these stations read, as a Pick over the SAME `deps` object (receiver
52
- * preserved for `deps.onError?.()`): the write-protection table and its data root, the tracer, the error face. */
53
- deps: Pick<RunnerDeps, "writeProtectedPaths" | "memoryEngineDir" | "tracer" | "onError">;
52
+ * preserved for `deps.onError?.()`): the write-protection table and its data root, the tracer, the error face, and
53
+ * the notice sink (#648: the gate's unresolvable-ask exit delivers `delegation.ask_unresolvable` through it). */
54
+ deps: Pick<RunnerDeps, "writeProtectedPaths" | "memoryEngineDir" | "tracer" | "onError" | "onNotice">;
54
55
  /** borrowed-mutable — the protocol-tools phase's arming belt. Writer here: `armed` ← the registration predicate, the
55
56
  * one write; the refresh seam reads it. */
56
57
  toolCallGateArmedRef: {
@@ -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, 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
@@ -74,6 +78,7 @@ export declare class Runner implements RunnerSelfSeat {
74
78
  agents?: AgentDefinition[];
75
79
  builtinAgents?: boolean;
76
80
  models?: Record<string, Model>;
81
+ roles?: ModelRoles;
77
82
  };
78
83
  /** design/125 (实现审 B1) — the deployment-level gating baseline, exposed READ-ONLY so thin
79
84
  * assemblers over the Runner (`runSpec`) can COMPOSE with it. The task-level engine semantic is a
@@ -87,38 +92,35 @@ export declare class Runner implements RunnerSelfSeat {
87
92
  /** Acquire the lock for a sessionId; returns a release fn. New (undefined) sessions need no lock. */
88
93
  private acquireSessionLock;
89
94
  /**
90
- * Hot-swap the model catalog (and optionally the tier bindings) without restarting the process or
91
- * rebuilding the Runner the deployment seat that makes "switching models" a zero-restart
92
- * 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.
93
100
  *
94
- * (Precisely: the constructor runs {@link expandTiers} once and keeps a private expanded copy **only
95
- * when `RunnerDeps.tiers` is configured** that is the arm where mutating the shared table after
96
- * construction provably never took effect. A tiers-less deployment's Runner holds the caller's own
97
- * `models` object BY REFERENCE, so mutating it after construction does leak through; that is an
98
- * accident of the expansion being a no-op, not a contract, and this verb is still the supported way
99
- * to change a generation it is what validates, announces, and computes the pairing disclosure.)
100
- *
101
- * Semantics:
102
- * - **Atomic**: the candidate catalog is tier-expanded and validated FIRST (an illegal tier
103
- * binding throws exactly like the constructor's boot error) on any throw the current
104
- * generation stays in force, untouched.
105
- * - **In-flight tasks keep their generation** (natural snapshot): a running task resolved its
106
- * `Model` object at prepare time and holds that reference; the swap changes what FUTURE
107
- * prepares (and auto-sourced agents' string refs {@link agentCatalog} copies per read)
108
- * resolve. Same-name redirects therefore never re-route or re-price a task mid-run; the
109
- * divergence window is exactly the in-flight tasks' lifetime, by design.
110
- * - `tiers` omitted the current tier bindings are kept (and re-applied over the new models);
111
- * explicitly passed (including `undefined`) replaced.
112
- * - Success is announced via `config.models_swapped` (models/tiers counts key material only,
113
- * never the catalog itself). A swap may additionally emit the advisory
114
- * `route.base_url_changed_key_unchanged` notice (deliberately conservative see its entry in
115
- * the notice directory) for same-name entries whose URL moved while the Model-visible
116
- * 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.
117
122
  */
118
- swapModels(next: {
119
- models: Record<string, Model>;
120
- tiers?: Record<string, ModelRef>;
121
- }): void;
123
+ swapDeps(next: SwappableDeps): void;
122
124
  /** one-shot brain-routed utility query (see {@link runSideQuery} for the full contract):
123
125
  * preserves system/multi-turn messages/tool DEFINITIONS, routes through the deployment's brain with
124
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();
@@ -138,7 +141,7 @@ export class Runner {
138
141
  return {
139
142
  ...(this.deps.agents ? { agents: [...this.deps.agents] } : {}),
140
143
  ...(this.deps.builtinAgents !== undefined ? { builtinAgents: this.deps.builtinAgents } : {}),
141
- ...(this.deps.models ? { models: { ...this.deps.models } } : {}),
144
+ ...(this.deps.models ? { models: { ...this.deps.models } } : {}), ...(this.deps.roles ? { roles: { ...this.deps.roles } } : {}),
142
145
  };
143
146
  }
144
147
  get gateBaseline() {
@@ -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