@sema-agent/server 7.2.0 → 7.4.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 (140) hide show
  1. package/README.md +2 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +26 -1
  4. package/dist/approval-ask-machine.d.ts +39 -0
  5. package/dist/approval-ask-machine.js +101 -0
  6. package/dist/approval-card.d.ts +244 -0
  7. package/dist/approval-card.js +237 -0
  8. package/dist/approval-deny-reasons.d.ts +56 -0
  9. package/dist/approval-deny-reasons.js +54 -0
  10. package/dist/approval-reconciler.d.ts +174 -0
  11. package/dist/approval-reconciler.js +307 -0
  12. package/dist/boot/coordinators.d.ts +1 -0
  13. package/dist/boot/coordinators.js +39 -4
  14. package/dist/boot/deferred-sandbox-path-env.d.ts +99 -0
  15. package/dist/boot/deferred-sandbox-path-env.js +279 -0
  16. package/dist/boot/execution-env.js +11 -1
  17. package/dist/boot/lexical-path-env.d.ts +10 -0
  18. package/dist/boot/lexical-path-env.js +88 -0
  19. package/dist/boot/reapers.d.ts +34 -0
  20. package/dist/boot/reapers.js +198 -23
  21. package/dist/boot/resolve-spec.js +97 -33
  22. package/dist/capabilities/center-prompts.js +4 -1
  23. package/dist/capabilities/oa-tools.d.ts +15 -0
  24. package/dist/capabilities/oa-tools.js +54 -0
  25. package/dist/config-types.d.ts +68 -1
  26. package/dist/config.d.ts +1 -0
  27. package/dist/config.js +138 -2
  28. package/dist/elicitation.d.ts +4 -0
  29. package/dist/elicitation.js +7 -3
  30. package/dist/finance/cost-taxonomy.d.ts +34 -0
  31. package/dist/finance/cost-taxonomy.js +26 -0
  32. package/dist/hooks/hook-runner.js +32 -0
  33. package/dist/http/routes/capabilities.js +14 -0
  34. package/dist/http/routes/diagnostics.d.ts +84 -0
  35. package/dist/http/routes/diagnostics.js +140 -0
  36. package/dist/http/routes/runs.d.ts +1 -0
  37. package/dist/http/routes/runs.js +548 -16
  38. package/dist/http/routes/tasks.js +175 -12
  39. package/dist/http/server.d.ts +6 -1
  40. package/dist/http/server.js +120 -4
  41. package/dist/http/sse-log.d.ts +51 -0
  42. package/dist/http/sse-log.js +64 -0
  43. package/dist/http/wire-types.d.ts +20 -5
  44. package/dist/main.js +35 -4
  45. package/dist/observability/fail-open.d.ts +98 -0
  46. package/dist/observability/fail-open.js +216 -0
  47. package/dist/observability/prompt-manifest.d.ts +13 -0
  48. package/dist/observability/prompt-manifest.js +8 -0
  49. package/dist/plugins/approval-ask-store-memory.d.ts +38 -0
  50. package/dist/plugins/approval-ask-store-memory.js +299 -0
  51. package/dist/plugins/approval-ask-store-sql.d.ts +341 -0
  52. package/dist/plugins/approval-ask-store-sql.js +705 -0
  53. package/dist/plugins/approval-store-sql.d.ts +116 -0
  54. package/dist/plugins/approval-store-sql.js +151 -0
  55. package/dist/plugins/background-agent-store-sql.js +20 -1
  56. package/dist/plugins/checkpoint-store-sql.d.ts +84 -9
  57. package/dist/plugins/checkpoint-store-sql.js +297 -16
  58. package/dist/plugins/file-workflow-journal-store.d.ts +12 -0
  59. package/dist/plugins/file-workflow-journal-store.js +12 -0
  60. package/dist/plugins/local-checkpoint-store.d.ts +6 -5
  61. package/dist/plugins/local-checkpoint-store.js +4 -0
  62. package/dist/plugins/pg-approval-store.d.ts +9 -0
  63. package/dist/plugins/pg-approval-store.js +9 -0
  64. package/dist/plugins/pg-breaker-state.d.ts +8 -0
  65. package/dist/plugins/pg-breaker-state.js +8 -0
  66. package/dist/plugins/pg-checkpoint-store.d.ts +10 -0
  67. package/dist/plugins/pg-checkpoint-store.js +10 -0
  68. package/dist/plugins/pg-file-snapshot-store.d.ts +8 -0
  69. package/dist/plugins/pg-file-snapshot-store.js +8 -0
  70. package/dist/plugins/pg-image-bake.d.ts +12 -0
  71. package/dist/plugins/pg-image-bake.js +11 -0
  72. package/dist/plugins/pg-image-index.d.ts +12 -0
  73. package/dist/plugins/pg-image-index.js +11 -0
  74. package/dist/plugins/pg-outcome-ledger.d.ts +12 -0
  75. package/dist/plugins/pg-outcome-ledger.js +11 -0
  76. package/dist/plugins/pg-pool.js +11 -0
  77. package/dist/plugins/pg-resume-anchor-store.d.ts +7 -0
  78. package/dist/plugins/pg-resume-anchor-store.js +7 -0
  79. package/dist/plugins/pg-run-store.d.ts +9 -0
  80. package/dist/plugins/pg-run-store.js +9 -0
  81. package/dist/plugins/pg-session-policy-store.d.ts +7 -0
  82. package/dist/plugins/pg-session-policy-store.js +7 -0
  83. package/dist/plugins/pg-session-store.d.ts +12 -0
  84. package/dist/plugins/pg-session-store.js +12 -0
  85. package/dist/plugins/pg-tool-result-store.d.ts +9 -0
  86. package/dist/plugins/pg-tool-result-store.js +9 -0
  87. package/dist/plugins/pg-workflow-journal-store.d.ts +9 -0
  88. package/dist/plugins/pg-workflow-journal-store.js +9 -0
  89. package/dist/plugins/pg-workflow-run-store.d.ts +9 -0
  90. package/dist/plugins/pg-workflow-run-store.js +9 -0
  91. package/dist/plugins/store-backend.d.ts +18 -0
  92. package/dist/plugins/store-backend.js +10 -0
  93. package/dist/plugins/tidb-approval-store.d.ts +8 -0
  94. package/dist/plugins/tidb-approval-store.js +8 -0
  95. package/dist/plugins/tidb-breaker-state.d.ts +7 -0
  96. package/dist/plugins/tidb-breaker-state.js +7 -0
  97. package/dist/plugins/tidb-checkpoint-store.d.ts +9 -0
  98. package/dist/plugins/tidb-checkpoint-store.js +9 -0
  99. package/dist/plugins/tidb-file-snapshot-store.d.ts +8 -0
  100. package/dist/plugins/tidb-file-snapshot-store.js +8 -0
  101. package/dist/plugins/tidb-image-bake.d.ts +12 -0
  102. package/dist/plugins/tidb-image-bake.js +11 -0
  103. package/dist/plugins/tidb-image-index.d.ts +12 -0
  104. package/dist/plugins/tidb-image-index.js +11 -0
  105. package/dist/plugins/tidb-outcome-ledger.d.ts +12 -0
  106. package/dist/plugins/tidb-outcome-ledger.js +12 -0
  107. package/dist/plugins/tidb-pool.js +27 -4
  108. package/dist/plugins/tidb-resume-anchor-store.d.ts +7 -0
  109. package/dist/plugins/tidb-resume-anchor-store.js +7 -0
  110. package/dist/plugins/tidb-run-store.d.ts +10 -0
  111. package/dist/plugins/tidb-run-store.js +9 -0
  112. package/dist/plugins/tidb-session-policy-store.d.ts +7 -0
  113. package/dist/plugins/tidb-session-policy-store.js +7 -0
  114. package/dist/plugins/tidb-tool-result-store.d.ts +8 -0
  115. package/dist/plugins/tidb-tool-result-store.js +10 -0
  116. package/dist/plugins/tidb-workflow-journal-store.d.ts +9 -0
  117. package/dist/plugins/tidb-workflow-journal-store.js +9 -0
  118. package/dist/plugins/tidb-workflow-run-store.d.ts +10 -0
  119. package/dist/plugins/tidb-workflow-run-store.js +10 -0
  120. package/dist/plugins/workflow-journal-limits.d.ts +12 -0
  121. package/dist/plugins/workflow-journal-limits.js +12 -0
  122. package/dist/question.d.ts +21 -14
  123. package/dist/question.js +83 -34
  124. package/dist/runs.d.ts +16 -1
  125. package/dist/runs.js +61 -3
  126. package/dist/runtime-caps-resolver.d.ts +7 -1
  127. package/dist/runtime-caps-resolver.js +65 -3
  128. package/dist/sema-registry.d.ts +41 -0
  129. package/dist/sema-registry.js +40 -0
  130. package/dist/spec-fields.d.ts +4 -0
  131. package/dist/spec-fields.js +6 -0
  132. package/dist/task-settings.d.ts +36 -15
  133. package/dist/task-settings.js +19 -5
  134. package/dist/tool-approval.d.ts +296 -3
  135. package/dist/tool-approval.js +1074 -50
  136. package/dist/trace/core-keyset-guard.d.ts +2 -2
  137. package/dist/trace/ledger-sink.js +14 -1
  138. package/dist/trace/project.d.ts +90 -0
  139. package/dist/trace/project.js +188 -0
  140. package/package.json +5 -4
@@ -1,4 +1,4 @@
1
- import { type AskQuestionRequest, type QuestionAnswer, type AskQuestion } from "@sema-agent/core";
1
+ import { type AskQuestionRequest, type OnQuestionOutcome, type QuestionAnswer, type AskQuestion } from "@sema-agent/core";
2
2
  /** A live question frame delivered to whoever tails this run's stream. `type` IS the SSE event name (mirrors the
3
3
  * named-event convention; the payload also carries `type` so a proxy that strips event names still works). The shell
4
4
  * renders `question` as a dialog and dismisses on `question_complete`. */
@@ -6,11 +6,14 @@ export interface QuestionFrame {
6
6
  type: "question" | "question_complete";
7
7
  questionId: string;
8
8
  /** "question" only: the model's structured questions, secret-redacted. UNTRUSTED-for-display (shell renders; never
9
- * re-feeds a model). Absent on an over-cap payload (the ask then headless-defaults). */
9
+ * re-feeds a model). Absent on an over-cap payload (the ask then reports unavailable — no frame is emitted at all). */
10
10
  questions?: AskQuestion[];
11
11
  /** "question_complete" only: whether the human answered (`answered`) or the ask was released unanswered
12
- * (`unanswered` — ttl/abort → the model got the headless default). Cosmetic dialog-dismiss. */
12
+ * (`unanswered` — ttl/abort → core was told nobody was reachable). Cosmetic dialog-dismiss. */
13
13
  outcome?: "answered" | "unanswered";
14
+ /** Server-signed emit timestamp (additive, [2805]§五→[2806]). Server-minted side-frame ⇒ no core-stream
15
+ * `eventId` to carry (absence is honest); this is the frame's own time coordinate. */
16
+ serverNowMs?: number;
14
17
  }
15
18
  /** The per-run context `onQuestion` recovers via ALS. `emit` delivers a frame to the run's live stream; `abortSignal`
16
19
  * (the run's own cancel signal) releases a question parked awaiting a human when the run aborts. */
@@ -28,12 +31,14 @@ export interface QuestionRunContext {
28
31
  * background leg (emit appends to the durable events tail, readable whenever a client attaches). */
29
32
  deliverable?: () => boolean;
30
33
  }
34
+ /** The per-run question window the deployment owns (core caps nothing). Env: `QUESTION_MAX_CONCURRENT_PER_RUN` /
35
+ * `QUESTION_MAX_TOTAL_PER_RUN` / `QUESTION_TTL_MS` (see `ServiceConfig.questionThrottle`). */
31
36
  export interface QuestionThrottle {
32
- /** Max concurrent in-flight questions per run leg (parallel tool calls can each ask). Breach ⇒ headless default. */
37
+ /** Max concurrent in-flight questions per run leg (parallel tool calls can each ask). Breach ⇒ unavailable. */
33
38
  maxConcurrentPerRun: number;
34
- /** Max total questions one run leg surfaces to the human. Breach ⇒ headless default (flood defense). */
39
+ /** Max total questions one run leg surfaces to the human. Breach ⇒ unavailable (flood defense). */
35
40
  maxTotalPerRun: number;
36
- /** An unanswered question auto-releases (headless default) after this (ms) — the human walked away; don't hold core. */
41
+ /** An unanswered question auto-releases as unavailable after this (ms) — the human walked away; don't hold core. */
37
42
  ttlMs: number;
38
43
  }
39
44
  export declare const DEFAULT_QUESTION_THROTTLE: QuestionThrottle;
@@ -51,7 +56,7 @@ export declare function parseQuestionResponse(body: unknown): {
51
56
  * Coordinates AskUserQuestion HITL for the singleton runner. Process-local + same-replica (the pending map is in memory,
52
57
  * like ElicitationCoordinator / steerableRuns): a respond that lands on another replica finds nothing → 404. Present
53
58
  * (passed into `RunnerDeps.onQuestion` + the respond route) ONLY when `ASK_QUESTION_ENABLED` — absent ⇒ core mounts the
54
- * AskUserQuestion tool with the headless default (a run never hangs; the model just can't get a live answer).
59
+ * AskUserQuestion tool with no seam at all (`seam_absent` continuation; the model just can't get a live answer).
55
60
  */
56
61
  export declare class QuestionCoordinator {
57
62
  private readonly als;
@@ -60,12 +65,13 @@ export declare class QuestionCoordinator {
60
65
  private readonly throttle;
61
66
  constructor(throttle?: QuestionThrottle);
62
67
  /** Run `fn` with the per-run question context ambient. On exit, release any still-pending question for this run (a
63
- * live-only question cannot outlive its leg → headless default) and drop the run's counters (no leak). */
68
+ * live-only question cannot outlive its leg → unavailable) and drop the run's counters (no leak). */
64
69
  runWithContext<T>(ctx: QuestionRunContext, fn: () => Promise<T>): Promise<T>;
65
- /** `RunnerDeps.onQuestion`. core calls this when the agent's AskUserQuestion tool fires; the resolved answer is what
66
- * the model sees. Headless default (`NO_HUMAN`) in every case with no live human, so the run never hangs. Arrow
67
- * property so it can be passed as `onQuestion: coordinator.question` with `this` bound. */
68
- question: (req: AskQuestionRequest, signal?: AbortSignal) => Promise<QuestionAnswer>;
70
+ /** `RunnerDeps.onQuestion`. core calls this when the agent's AskUserQuestion tool fires; the resolved outcome is what
71
+ * core acts on a {@link QuestionAnswer} ONLY when a human really answered, otherwise `{kind:"unavailable"}` (core
72
+ * then parks on a durable leg / continues with the `declined_unavailable` card on a non-durable one; either way the
73
+ * run never hangs). Arrow property so it can be passed as `onQuestion: coordinator.question` with `this` bound. */
74
+ question: (req: AskQuestionRequest, signal?: AbortSignal) => Promise<OnQuestionOutcome>;
69
75
  /** `POST /v1/questions/:id/respond` — resolve a parked question with the shell's answer. Owner-gated with a 404 (no
70
76
  * existence oracle): a non-owner AND an unknown id (answered / expired / wrong replica) both get 404. Returns the
71
77
  * HTTP {status, body}; the HTTP layer owns auth (gatedPrincipal + REQUIRE_PRINCIPAL) before calling. */
@@ -80,9 +86,10 @@ export declare class QuestionCoordinator {
80
86
  * resolve-spec 的 durable question policy 用它在**判决时**分腿:有活流 ⇒ allow(问活人),无 ⇒
81
87
  * ask(durable park)。ALS 让这个判断天然 per-leg,policy 组装期不必预知腿别。
82
88
  * 🔴 判据是「投递面此刻可达」而不只是「ALS 在场」(复审 A1):detach 腿断连后 run 仍在本作用域里跑,
83
- * 只判 ALS 会把一条谁也收不到的问题判成 allow(挂满 ttl 后以空答喂模型),而 park 才是它该走的腿。
89
+ * 只判 ALS 会把一条谁也收不到的问题判成 allow——挂满 ttl 后以 unavailable 结算,非 durable 形 core
90
+ * 就此合成续跑,活人再没机会答;而 park 才是它该走的腿。
84
91
  * 谓词缺席 ⇒ 恒可达(后台腿的 durable events tail 语义)。谓词本身抛错按不可达处理:判决面
85
- * fail-closed 到 park(park 可由运维补答,空答不可回收)。 */
92
+ * fail-closed 到 park(park 可由运维补答,续跑掉的问题不可回收)。 */
86
93
  hasLiveContext(): boolean;
87
94
  private countersFor;
88
95
  }
package/dist/question.js CHANGED
@@ -15,29 +15,39 @@
15
15
  * (the HTTP boundary) and REDACTS secrets from the model-authored QUESTION text before it is persisted/streamed (the
16
16
  * same redact-at-write contract as elicitation's `message`/tool outputs — the question is shown to a human, never
17
17
  * re-fed to a model, so no `delimitUntrusted` is needed on it). Deployment responsibilities core delegates: THROTTLE
18
- * (bound concurrency/total/ttl per run — an over-eager agent could flood the human) and no-live-human FALLBACK (a leg
19
- * with no stream returns the headless "no answer" default so the model proceeds instead of hanging).
18
+ * (bound concurrency/total/ttl per run — an over-eager agent could flood the human).
19
+ *
20
+ * 🔴 VERDICT vs LANDING are separate jobs (#166 裁1). This coordinator is the deployment's HONEST reporter of one fact:
21
+ * "no human answered this". Every arm with no real human answer — no run context, the run aborted, the throttle window
22
+ * is spent, the question cannot be presented, the TTL expired (incl. the run-exit sweep) — returns core's
23
+ * `{kind:"unavailable"}`. It NEVER synthesizes an empty answer set: an empty `{answers:[]}` reads to core as "a human
24
+ * was here and picked nothing", which on a durable deployment consumes the pending approval and hands the model a
25
+ * fabricated human verdict instead of parking the question where an operator can still answer it. Choosing the landing
26
+ * is CORE's job: a durable leg parks a checkpoint (operator answers later via decide), a non-durable leg continues with
27
+ * the `declined_unavailable` synthetic-continuation card (the model is told nobody is reachable and proceeds on its own
28
+ * judgment — a run with no human still never hangs). Only a REAL human answer returns a {@link QuestionAnswer}.
20
29
  *
21
30
  * ROUTING mirrors elicitation: `onQuestion` lives on the SINGLETON runner's `RunnerDeps` (the request carries no taskId),
22
31
  * so the run leg establishes a per-run AsyncLocalStorage context (taskId throttle key, owner respond-gate, `emit`
23
- * closure). `node:async_hooks` propagates it through every await; no context ⇒ headless default (never a hang).
32
+ * closure). `node:async_hooks` propagates it through every await; no context ⇒ unavailable (never a hang).
24
33
  */
25
34
  import { AsyncLocalStorage } from "node:async_hooks";
26
35
  import { uuidv7 } from "@sema-agent/core";
27
36
  import { redactDeep } from "./trace/redact.js";
37
+ import { recordFailOpen } from "./observability/fail-open.js";
28
38
  /** Size bound on the model-authored questions payload before it is persisted to the durable log + streamed (parity
29
39
  * with elicitation's MAX_ELICIT_* — an over-large questions blob amplifies into the socket/log). Over the cap ⇒ the
30
- * questions are dropped and the ask headless-defaults (a question the human can't see can't be answered). */
40
+ * questions are dropped and the ask reports unavailable (a question the human can't see can't be answered). */
31
41
  const MAX_QUESTIONS_BYTES = 16384;
32
42
  export const DEFAULT_QUESTION_THROTTLE = {
33
43
  maxConcurrentPerRun: 2,
34
44
  maxTotalPerRun: 20,
35
45
  ttlMs: 5 * 60_000,
36
46
  };
37
- /** The headless "no live human answered" result an EMPTY answer set (no question got a selection). core's
38
- * AskUserQuestion tool renders this to the model as "no option selected" so it proceeds with its own judgment instead
39
- * of hanging (parity with the `onQuestion===undefined` headless default a run with no human never blocks). */
40
- const NO_HUMAN = { answers: [] };
47
+ /** The honest "nobody answered this" outcome. Frozen + shared: it is pure data core reads through
48
+ * `classifyQuestionOutcome`, and it must never be confused with an empty ANSWER (which claims a human chose nothing
49
+ * and, on a durable leg, spends the approval that park would have preserved). */
50
+ const UNAVAILABLE = Object.freeze({ kind: "unavailable" });
41
51
  /** Validate the respond body = core's {@link QuestionAnswer}. `selected`/`note` are OPAQUE passthrough (core fences
42
52
  * them: `selected ⊆ options`, `note` untrusted-fenced) — we validate only the outer SHAPE (answers[] of {header,
43
53
  * selected:string[], note?}), never the option semantics. */
@@ -65,7 +75,7 @@ export function parseQuestionResponse(body) {
65
75
  }
66
76
  /** redactDeep + size-bound the UNTRUSTED model-authored questions before they are persisted/streamed (a question's
67
77
  * free-text — question/header/option label+description — can echo a secret from the model's context). Over the byte
68
- * cap OR unserializable ⇒ undefined (drop; the ask headless-defaults). */
78
+ * cap OR unserializable ⇒ undefined (drop; the ask reports unavailable). */
69
79
  function boundQuestions(questions) {
70
80
  const redacted = redactDeep(questions);
71
81
  try {
@@ -81,7 +91,7 @@ function boundQuestions(questions) {
81
91
  * Coordinates AskUserQuestion HITL for the singleton runner. Process-local + same-replica (the pending map is in memory,
82
92
  * like ElicitationCoordinator / steerableRuns): a respond that lands on another replica finds nothing → 404. Present
83
93
  * (passed into `RunnerDeps.onQuestion` + the respond route) ONLY when `ASK_QUESTION_ENABLED` — absent ⇒ core mounts the
84
- * AskUserQuestion tool with the headless default (a run never hangs; the model just can't get a live answer).
94
+ * AskUserQuestion tool with no seam at all (`seam_absent` continuation; the model just can't get a live answer).
85
95
  */
86
96
  export class QuestionCoordinator {
87
97
  als = new AsyncLocalStorage();
@@ -92,7 +102,7 @@ export class QuestionCoordinator {
92
102
  this.throttle = throttle;
93
103
  }
94
104
  /** Run `fn` with the per-run question context ambient. On exit, release any still-pending question for this run (a
95
- * live-only question cannot outlive its leg → headless default) and drop the run's counters (no leak). */
105
+ * live-only question cannot outlive its leg → unavailable) and drop the run's counters (no leak). */
96
106
  runWithContext(ctx, fn) {
97
107
  return this.als.run(ctx, async () => {
98
108
  try {
@@ -101,31 +111,34 @@ export class QuestionCoordinator {
101
111
  finally {
102
112
  for (const [, p] of [...this.pending]) {
103
113
  if (p.taskId === ctx.taskId)
104
- p.settle(NO_HUMAN, "unanswered");
114
+ p.settle(UNAVAILABLE, "unanswered");
105
115
  }
106
116
  this.counters.delete(ctx.taskId);
107
117
  }
108
118
  });
109
119
  }
110
- /** `RunnerDeps.onQuestion`. core calls this when the agent's AskUserQuestion tool fires; the resolved answer is what
111
- * the model sees. Headless default (`NO_HUMAN`) in every case with no live human, so the run never hangs. Arrow
112
- * property so it can be passed as `onQuestion: coordinator.question` with `this` bound. */
120
+ /** `RunnerDeps.onQuestion`. core calls this when the agent's AskUserQuestion tool fires; the resolved outcome is what
121
+ * core acts on a {@link QuestionAnswer} ONLY when a human really answered, otherwise `{kind:"unavailable"}` (core
122
+ * then parks on a durable leg / continues with the `declined_unavailable` card on a non-durable one; either way the
123
+ * run never hangs). Arrow property so it can be passed as `onQuestion: coordinator.question` with `this` bound. */
113
124
  question = async (req, signal) => {
114
125
  const ctx = this.als.getStore();
115
126
  // No run context (a leg not wrapped by runWithContext — synchronous /v1/tasks, verify/cascade, or a leader sub-task
116
- // with no live stream) ⇒ headless default. Sub-tasks run in the parent's ALS frame → a child's question inherits the
127
+ // with no live stream) ⇒ nobody to ask. Sub-tasks run in the parent's ALS frame → a child's question inherits the
117
128
  // top-level run's ctx (one human, one stream, one throttle budget), same as elicitation.
118
129
  if (!ctx)
119
- return NO_HUMAN;
130
+ return UNAVAILABLE;
131
+ // Aborted (call signal or the run's own): whatever comes back has no consumer, so claiming a human answered is a lie.
120
132
  if (signal?.aborted || ctx.abortSignal?.aborted)
121
- return NO_HUMAN;
122
- // THROTTLE (service's job — core imposes none): bound concurrency + total. Breach ⇒ headless default.
133
+ return UNAVAILABLE;
134
+ // THROTTLE (service's job — core imposes none): bound concurrency + total. Breach ⇒ unavailable, never a denial and
135
+ // never a fabricated answer — a spent window is a "not now", which is exactly what park/continue is for.
123
136
  const rc = this.countersFor(ctx.taskId);
124
137
  if (rc.inflight >= this.throttle.maxConcurrentPerRun || rc.total >= this.throttle.maxTotalPerRun)
125
- return NO_HUMAN;
138
+ return UNAVAILABLE;
126
139
  const questions = boundQuestions(req.questions);
127
140
  if (!questions)
128
- return NO_HUMAN; // over-cap / unserializable → can't show it → headless
141
+ return UNAVAILABLE; // over-cap / unserializable → can't show it → nobody can answer it
129
142
  const id = uuidv7();
130
143
  rc.inflight += 1; // reserve the concurrency slot; total charged only on a successful emit
131
144
  let done = false;
@@ -147,8 +160,10 @@ export class QuestionCoordinator {
147
160
  resolveAnswer(a);
148
161
  };
149
162
  let lastOutcome = "unanswered";
150
- const onAbort = () => settle(NO_HUMAN, "unanswered");
151
- timer = setTimeout(() => settle(NO_HUMAN, "unanswered"), this.throttle.ttlMs);
163
+ const onAbort = () => settle(UNAVAILABLE, "unanswered");
164
+ // TTL: the human walked away. Release as unavailable — core decides whether that parks (durable, operator can still
165
+ // answer) or continues (non-durable); holding core past the window is not one of the choices.
166
+ timer = setTimeout(() => settle(UNAVAILABLE, "unanswered"), this.throttle.ttlMs);
152
167
  timer.unref?.();
153
168
  signal?.addEventListener("abort", onAbort, { once: true });
154
169
  ctx.abortSignal?.addEventListener("abort", onAbort, { once: true });
@@ -156,20 +171,53 @@ export class QuestionCoordinator {
156
171
  this.pending.set(id, { settle, owner: ctx.owner, taskId: ctx.taskId });
157
172
  // Emit the OPEN frame AWAITED so its durable append is ORDERED ahead of the answer/completion/terminal appends (a
158
173
  // fire-and-forget append can commit out of seq order and be skipped by the monotonic events-tail cursor). A FAILED
159
- // emit headless-defaults (nobody can answer) and must NOT burn the per-run total.
160
- try {
161
- await ctx.emit({ type: "question", questionId: id, questions });
174
+ // emit settles unavailable (nobody can answer a frame that never arrived) and must NOT burn the per-run total.
175
+ // 🔴 RACED against the settlement (codex 复审 R2-high): a STALLED delivery (a durable append that never settles, a
176
+ // half-open socket) is a third shape next to ok/threw, and a bare `await ctx.emit` on it outlives TTL *and* abort —
177
+ // settle would resolve `answerP` while this frame is still parked on the emit, so `question()` never returns, core's
178
+ // callTool never unblocks and the leg cannot even reach its own cleanup. "The window always releases" has to hold
179
+ // for the stalled shape too, so the await ends at whichever comes first. Ordering is unchanged on the normal path
180
+ // (the settlement is still pending there, so the emit is still fully awaited).
181
+ // The loser of the race keeps its own handlers — an emit that rejects AFTER the race must not surface as an
182
+ // unhandled rejection.
183
+ const emitted = (async () => ctx.emit({ type: "question", questionId: id, questions, serverNowMs: Date.now() }))()
184
+ .then(() => "ok", () => "failed");
185
+ const delivery = await Promise.race([emitted, answerP.then(() => "stalled")]);
186
+ if (delivery === "ok") {
162
187
  rc.total += 1;
163
188
  }
164
- catch {
165
- settle(NO_HUMAN, "unanswered");
189
+ else if (delivery === "failed") {
190
+ // #157(codex 复审 MEDIUM,2026-08-07):投递失败 ⇒ 人在环这道门被**一次故障**跳过,模型自己往下判。
191
+ // 「绝不把 run 挂死」是刻意的产品姿态,所以方向不改;但它是 P-DEBT 不是合法兜底——债要在遥测里显形。
192
+ // #166 后本臂结算成 unavailable(而非空答):留痕语义不变(一次投递故障仍可能让人在环被跳过——非
193
+ // durable 形 core 合成续跑),durable 形则改由 core park,债的方向与本记账口径一致。
194
+ recordFailOpen("server.question.open-frame-undelivered", `question=${id}`);
195
+ settle(UNAVAILABLE, "unanswered");
166
196
  }
197
+ // delivery==="stalled":窗已由 TTL/abort/清扫结算,这一问就此让位;账在下面那个迟到臂里补(投递还没
198
+ // 判出成败,现在既不能计费也不能记债)。
167
199
  const answer = await answerP;
168
200
  // Completion breadcrumb (dialog dismiss) — FIRE-AND-FORGET so a slow durable append can never delay returning the
169
- // answer to core. Best-effort + ordering-uncritical (the shell already has the answer via respond).
170
- void Promise.resolve()
171
- .then(() => ctx.emit({ type: "question_complete", questionId: id, outcome: lastOutcome }))
172
- .catch(() => undefined);
201
+ // answer to core. Best-effort (the shell already has the answer via respond), but CHAINED BEHIND the open frame and
202
+ // BRANCHED on how that frame ended (codex 复审 R3):
203
+ // · 送达(含**迟到**送达) 人真看见了这张卡:补烧总量额度(否则「反复悬挂再落地」是一条绕过刷屏帽的
204
+ // 路子),再发消卡帧把它消解掉;
205
+ // · 投递失败(含**迟到**失败)⇒ 没有卡可消解,发消卡帧只会在流里留一条无主帧;此时补记那条 P-DEBT
206
+ // 留痕(同步臂已经记过的不会重复:那条腿的 delivery 不是 stalled)。
207
+ void emitted
208
+ .then((result) => {
209
+ if (result === "failed") {
210
+ if (delivery === "stalled")
211
+ recordFailOpen("server.question.open-frame-undelivered", `question=${id}`);
212
+ return undefined;
213
+ }
214
+ if (delivery === "stalled")
215
+ rc.total += 1;
216
+ return ctx.emit({ type: "question_complete", questionId: id, outcome: lastOutcome, serverNowMs: Date.now() });
217
+ })
218
+ // #157:F 类兜底(丢的是收尾渲染,不是答案)——合法,但必须留痕,否则"面包屑成片消失"只能靠
219
+ // 用户报"对话框不消解"发现。
220
+ .catch(() => recordFailOpen("server.question.complete-breadcrumb-dropped", `question=${id}`));
173
221
  return answer;
174
222
  };
175
223
  /** `POST /v1/questions/:id/respond` — resolve a parked question with the shell's answer. Owner-gated with a 404 (no
@@ -197,9 +245,10 @@ export class QuestionCoordinator {
197
245
  * resolve-spec 的 durable question policy 用它在**判决时**分腿:有活流 ⇒ allow(问活人),无 ⇒
198
246
  * ask(durable park)。ALS 让这个判断天然 per-leg,policy 组装期不必预知腿别。
199
247
  * 🔴 判据是「投递面此刻可达」而不只是「ALS 在场」(复审 A1):detach 腿断连后 run 仍在本作用域里跑,
200
- * 只判 ALS 会把一条谁也收不到的问题判成 allow(挂满 ttl 后以空答喂模型),而 park 才是它该走的腿。
248
+ * 只判 ALS 会把一条谁也收不到的问题判成 allow——挂满 ttl 后以 unavailable 结算,非 durable 形 core
249
+ * 就此合成续跑,活人再没机会答;而 park 才是它该走的腿。
201
250
  * 谓词缺席 ⇒ 恒可达(后台腿的 durable events tail 语义)。谓词本身抛错按不可达处理:判决面
202
- * fail-closed 到 park(park 可由运维补答,空答不可回收)。 */
251
+ * fail-closed 到 park(park 可由运维补答,续跑掉的问题不可回收)。 */
203
252
  hasLiveContext() {
204
253
  const ctx = this.als.getStore();
205
254
  if (ctx === undefined)
package/dist/runs.d.ts CHANGED
@@ -5,6 +5,7 @@ import type { Metrics } from "./observability/metrics.js";
5
5
  import type { ModelUsageTracker, PromptManifestTracker } from "./budget.js";
6
6
  import type { ElicitationCoordinator } from "./elicitation.js";
7
7
  import type { QuestionCoordinator } from "./question.js";
8
+ import { type ToolApprovalCoordinator } from "./tool-approval.js";
8
9
  import { type FleetRunPublisher } from "./fleet/fleet-bus.js";
9
10
  import { type WorkflowCompletionInbox } from "./orchestration/workflow-completion-inbox.js";
10
11
  import type { VerifyRoundsSpec } from "./http/verify-rounds.js";
@@ -250,7 +251,21 @@ completionDiagLog?: (msg: string, meta?: Record<string, unknown>) => void,
250
251
  sendUserFile?: SendUserFileEmitter,
251
252
  /** [998]② the shared prompt-manifest accumulator (tracer records at prepare; this leg drains the pending
252
253
  * record into a durable `prompt_assembled` event — turns/stream read it). undefined ⇒ not wired. */
253
- promptManifests?: PromptManifestTracker): Promise<void>;
254
+ promptManifests?: PromptManifestTracker,
255
+ /**
256
+ * #151 车3 刀 3b:**bg 腿的流内审批 ctx**(design/172 §4.3(b))。今天为止这条腿连 approval ALS 都没有
257
+ * ——`deps.onAsk` 拿不到 ctx ⇒ 恒 `"unavailable"` ⇒ durable park,一张卡都不产。接上之后,bg run 的
258
+ * 权限 ask 走本腿的 **durable events tail**(`GET /v1/runs/:id/events`,唯一带 SSE `id:` 的面)。
259
+ *
260
+ * `streamApprovalOn` = 协议上场判据(`resolveStreamApprovalGate`,由路由层求值后传进来 —— 本函数是纯
261
+ * 执行腿,不该自己读 backend/config)。为假 ⇒ 只接既有四键 ctx(呈卡口/腿轴都不接),本腿字节逐字不变。
262
+ */
263
+ approval?: {
264
+ coordinator: ToolApprovalCoordinator;
265
+ streamApprovalOn: boolean;
266
+ windowMs: number;
267
+ windowMarginMs: number;
268
+ }): Promise<void>;
254
269
  /** How often a running instance refreshes its run's updated_at (liveness, independent of events).
255
270
  * Must stay strictly below `runStaleSec` (asserted at startup) or the reaper would race live runs. */
256
271
  export declare const HEARTBEAT_MS = 30000;
package/dist/runs.js CHANGED
@@ -3,6 +3,7 @@ import { withPrincipal } from "./observability/principal-context.js";
3
3
  import { redactSecrets } from "./trace/redact.js";
4
4
  import { taskNotificationEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "./trace/project.js";
5
5
  import { createLedgerSink } from "./trace/ledger-sink.js";
6
+ import { createApprovalCardEmitter, resolveApprovalLeg } from "./tool-approval.js";
6
7
  import { fleetRunResiduals, isFleetAgentTerminalNotification } from "./fleet/fleet-bus.js";
7
8
  import { defaultSubagentTailBus, projectTailFrame } from "./fleet/subagent-tail-bus.js";
8
9
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "./orchestration/workflow-completion-inbox.js";
@@ -331,7 +332,16 @@ completionDiagLog,
331
332
  sendUserFile,
332
333
  /** [998]② the shared prompt-manifest accumulator (tracer records at prepare; this leg drains the pending
333
334
  * record into a durable `prompt_assembled` event — turns/stream read it). undefined ⇒ not wired. */
334
- promptManifests) {
335
+ promptManifests,
336
+ /**
337
+ * #151 车3 刀 3b:**bg 腿的流内审批 ctx**(design/172 §4.3(b))。今天为止这条腿连 approval ALS 都没有
338
+ * ——`deps.onAsk` 拿不到 ctx ⇒ 恒 `"unavailable"` ⇒ durable park,一张卡都不产。接上之后,bg run 的
339
+ * 权限 ask 走本腿的 **durable events tail**(`GET /v1/runs/:id/events`,唯一带 SSE `id:` 的面)。
340
+ *
341
+ * `streamApprovalOn` = 协议上场判据(`resolveStreamApprovalGate`,由路由层求值后传进来 —— 本函数是纯
342
+ * 执行腿,不该自己读 backend/config)。为假 ⇒ 只接既有四键 ctx(呈卡口/腿轴都不接),本腿字节逐字不变。
343
+ */
344
+ approval) {
335
345
  const startedAt = Date.now();
336
346
  metrics?.addGauge("runs_active", 1);
337
347
  fleetPublisher?.onStart(); // MF-Fleet: the row goes live (running) the moment the background run starts
@@ -372,6 +382,21 @@ promptManifests) {
372
382
  // The owning principal is this run's owner — thread it as the single-DB-fleet null-safe owner guard on every
373
383
  // run-store mutation/poll below (the run row was created with owner = principal ?? null).
374
384
  const owner = principal ?? null;
385
+ // #151 车3 刀 3b(§7.3 + §8.3):本腿的审批装配裁定走**三腿共用**的那一份判据。
386
+ // 🔴 codex 交叉复审 F3(2026-08-06 真 finding)的修:此前本腿**无条件**包 ALS,只把新协议的口与轴挂在
387
+ // `streamApprovalOn` 后面 —— 于是 ①开关关时本腿的行为变了(此前它根本没有 approval ctx,每只 ask 恒
388
+ // `"unavailable"` ⇒ 恒 park;包上之后变成活卡并等满窗,A-1 当场破),②窗=0 在本腿上不生效(照样落行、
389
+ // 注册资源、发帧,再等一个 0ms 的表)。现在两件都由下面这一个裁定统一管。
390
+ // 取值点 = **本腿开始执行的时刻** = `runInBackground` 进入(路由层 `void runInBackground(...)` 之后
391
+ // 立刻),不是请求到达时刻(§3.3:按任务创建至今算会系统性算错)。
392
+ const approvalLeg = resolveApprovalLeg({
393
+ streamApprovalOn: approval?.streamApprovalOn === true,
394
+ windowMs: approval?.windowMs ?? 0,
395
+ windowMarginMs: approval?.windowMarginMs ?? 0,
396
+ ...(typeof spec.limits?.maxWalltimeMs === "number" ? { legWalltimeMs: spec.limits.maxWalltimeMs } : {}),
397
+ nowMonotonicMs: performance.now(),
398
+ });
399
+ const legDeadlineMonotonic = approvalLeg.legDeadlineMonotonic;
375
400
  const heartbeat = setInterval(() => {
376
401
  // 鲁棒性批5 A5(2026-08-05):与下面 cancel/preempt 两条兄弟同族——此前裸吞错,store 持续故障期间本 run
377
402
  // 的心跳每拍静默落空、运维零信号,直到某个副本的 reapStale 把它误判死亡(心跳失败正是那个误判的前兆)。
@@ -476,6 +501,15 @@ promptManifests) {
476
501
  const { type, ...rest } = frame;
477
502
  return append(type, rest);
478
503
  };
504
+ // #151 车3 刀 3b(design/172 §4.3(b)):同款 durable-append emit,服务**审批**帧 —— 既有
505
+ // `tool_approval`/`tool_approval_complete` 走它,新协议的 `approval_request` 也走它(同一条投递面、
506
+ // 两种帧,§4.3(a) 的「不新增出口」在这条腿上的落实)。`type` 是 SSE 事件名,其余是 data。
507
+ // 撤卡帧(`approval_revoke`)**不接**:它是 live-only 语义(不进账本 —— reaper/收敛器那类调用点没有
508
+ // seq 可分配),而本腿的唯一投递面就是账本;丢帧的结构补偿是重连 preamble 的全量对账基准。
509
+ const approvalEmit = (frame) => {
510
+ const { type, ...rest } = frame;
511
+ return append(type, rest);
512
+ };
479
513
  try {
480
514
  // P1 ①② follow-on (core): drain this session's async-workflow completion inbox at the START of the
481
515
  // background leg (before ANY branch — verify/cascade/plain-stream all get it; parity with the sync leg's
@@ -742,10 +776,34 @@ promptManifests) {
742
776
  const withQuestion = () => question
743
777
  ? question.runWithContext({ taskId, owner: elicitOwner ?? owner, emit: questionEmit, abortSignal: cancelCtrl.signal }, withElicit)
744
778
  : withElicit();
779
+ // #151 车3 刀 3b:审批 ALS —— 本腿**首次**拿到卡面(此前 `deps.onAsk` 在这条腿上恒无 ctx ⇒
780
+ // `"unavailable"` ⇒ 恒 park)。嵌套序同样无关(各自独立 ALS)。
781
+ // 🔴 F3 修:包不包 ALS 由 `approvalLeg` 决定,不再是「协调器在场就包」。
782
+ // · `active === false`(协议没上场)⇒ 不包 ⇒ `deps.onAsk` 找不到 ctx ⇒ `"unavailable"` ⇒ core
783
+ // durable park —— 这正是本腿在协议之前的逐字行为(A-1)。
784
+ // · `windowZero`(运维关窗 / 贴 deadline)⇒ 同样不包。本腿没有 per-task `spec.onAsk` 装配点,
785
+ // 「不包 ALS」就是 sync 腿那个 immediate-unavailable 闭包在这条腿上的**等价形**(§8.2 已亲证:
786
+ // `approverUnavailable === true` 与「onAsk 缺席」在 park 结局上逐字等价)。
787
+ const withApproval = () => approval && approvalLeg.active && !approvalLeg.windowZero
788
+ ? approval.coordinator.runWithContext({
789
+ taskId,
790
+ owner: elicitOwner ?? owner,
791
+ emit: approvalEmit,
792
+ abortSignal: cancelCtrl.signal,
793
+ ...(spec.sessionId ? { sessionId: spec.sessionId } : {}),
794
+ // 同一条 durable tail(§4.3(b))。经共用的投递口工厂:账本 append 失败 ⇒ 如实算「这一路没
795
+ // 接下」(本腿只有这一个 sink,于是=整条没送到),协调器据此走 park 而不是挂到窗到期(R2-1)。
796
+ emitCard: createApprovalCardEmitter({ appendDurable: (f) => approvalEmit(f) }),
797
+ // bg run 的这条腿恒是**首腿**(park→resume 走的是 server.ts 的 resume 腿,那条自己带
798
+ // token 摘要);空串是首腿的真值,不是「未知」。
799
+ legKey: "",
800
+ ...(legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic } : {}),
801
+ }, withQuestion)
802
+ : withQuestion();
745
803
  // SendUserFile 帧与 question 帧同投递面(durable events tail,web 渲染文件卡);嵌套序无关(ALS 互不相交)。
746
804
  return sendUserFile
747
- ? sendUserFile.runWithContext({ taskId, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); } }, withQuestion)
748
- : withQuestion();
805
+ ? sendUserFile.runWithContext({ taskId, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); } }, withApproval)
806
+ : withApproval();
749
807
  });
750
808
  // Cancelled mid-stream and the generator ended WITHOUT a terminal event (abort can end the stream cleanly
751
809
  // rather than throwing) — finalize cancelled here so the run isn't left "running" for the reaper to catch.
@@ -30,6 +30,9 @@ import type { RuntimeCaps } from "@sema-agent/core";
30
30
  import type { EntitlementRuntimeCaps } from "@sema-agent/registry-core";
31
31
  import { fetchPrincipalCaps, type ExecutionRuling } from "./config-center/facade.js";
32
32
  import type { ScenarioRuling } from "./capabilities/scenarios.js";
33
+ import { type Logger } from "./observability/logger.js";
34
+ /** test-only: reset the once-per-process trace so each test observes a fresh "first hit". */
35
+ export declare function resetExecutionLaneDefaultTraceForTest(): void;
33
36
  export interface EntitlementsResolverOpts {
34
37
  baseUrl: string;
35
38
  token: string;
@@ -99,8 +102,11 @@ export interface PrincipalEntitlementsClient {
99
102
  * allowlist + this worker's lane (machine code on the wire, prose stays with the shell/web).
100
103
  * No ruling / required=false / caps blip ⇒ pass (fail-open + audit). `allowedLanes` is an
101
104
  * OPEN domain — unknown names simply never match this worker's lane.
105
+ *
106
+ * `logger` (optional, defaults to this module's `defaultLogger`) is an injection seam purely for the
107
+ * once-per-process default trace below (test observability) — it does not change enforcement.
102
108
  */
103
- export declare function gateExecutionLane(ruling: ExecutionRuling | undefined, ownLane: string): void;
109
+ export declare function gateExecutionLane(ruling: ExecutionRuling | undefined, ownLane: string, logger?: Logger): void;
104
110
  /**
105
111
  * Build the per-principal caps client: `resolveRuntimeCaps` matches core's seam signature
106
112
  * `(principal) => Promise<RuntimeCaps | undefined>`; `scenarioRuling` rides the SAME fetch/cache/in-flight
@@ -2,6 +2,48 @@ import { fetchPrincipalCaps } from "./config-center/facade.js";
2
2
  import { HttpError } from "./security.js";
3
3
  import { createLogger } from "./observability/logger.js";
4
4
  const defaultLogger = createLogger();
5
+ /** #157 / [2762]§五(sema-comms `audits/failopen-governance-176.md` §7 "已知在办 1 条"):module-scope,
6
+ * once-per-process record of which {@link gateExecutionLane} fail-open DEFAULT form has already been traced.
7
+ * Two forms are counted SEPARATELY (both currently resolve to the same "pass" default, but they are different
8
+ * facts about the world — a caller must be able to tell "we never got a ruling for this principal" apart from
9
+ * "center ruled, and explicitly said not-required"):
10
+ * - `"no_ruling"` — `ruling === undefined` (no principal / center absent-or-blip / older center missing the key).
11
+ * - `"required_false"` — a ruling WAS resolved and it explicitly says `required: false`.
12
+ * Each form logs ONCE per process at first hit (a debug line — this is an expected, non-alarming default, not a
13
+ * warning); every subsequent hit of the SAME form only increments the counter (no repeat log line — this is a
14
+ * per-task gate, so without the throttle a busy deployment would log once per task forever). Pure record-keeping:
15
+ * never changes {@link gateExecutionLane}'s pass/deny outcome. */
16
+ const executionLaneDefaultTrace = new Map();
17
+ /** test-only: reset the once-per-process trace so each test observes a fresh "first hit". */
18
+ export function resetExecutionLaneDefaultTraceForTest() {
19
+ executionLaneDefaultTrace.clear();
20
+ }
21
+ function traceExecutionLaneDefault(form, ownLane, logger) {
22
+ const hitCount = (executionLaneDefaultTrace.get(form) ?? 0) + 1;
23
+ executionLaneDefaultTrace.set(form, hitCount);
24
+ if (hitCount > 1)
25
+ return; // subsequent hits: silently counted only (see map above), no repeat log line
26
+ try {
27
+ // codex 交叉复审第二轮真发现(2026-08-05):a throwing `logger.debug` must NEVER turn this pure
28
+ // observability step into gateExecutionLane itself throwing — that would silently convert a fail-open
29
+ // PASS into an uncaught exception, the exact inversion this module's whole design fights elsewhere
30
+ // (see the FAIL-CLOSED/FAIL-OPEN posture doc above). The hit-count above already advanced, so a retry
31
+ // of the SAME form stays silent rather than storming — the trade-off is "lose one trace line", not
32
+ // "lose one task admission".
33
+ logger.debug("execution_lane_default_applied", {
34
+ dimension: "execution_lane",
35
+ form,
36
+ ownLane,
37
+ default: "pass",
38
+ note: form === "no_ruling"
39
+ ? "no execution-lane ruling was ever resolved for this principal (no principal / center absent-or-blip / older center without the key)"
40
+ : "center resolved a ruling and explicitly set required=false",
41
+ });
42
+ }
43
+ catch {
44
+ // tracing must never affect the fail-open admission outcome — see comment above.
45
+ }
46
+ }
5
47
  /** Map center's `EntitlementRuntimeCaps` → the subset core's engine enforces. `allowUltracode` (shell/UX) is
6
48
  * dropped on purpose (not a core primitive); `allowWorkflows` / `forceDurableGate` / `allowFork` are the three the
7
49
  * engine enforces. Returns undefined when no enforceable cap is set (= no restriction). FAIL-CLOSED defense-in-depth:
@@ -60,10 +102,19 @@ export function scopedTokenNeedsWorker(token, worker) {
60
102
  * allowlist + this worker's lane (machine code on the wire, prose stays with the shell/web).
61
103
  * No ruling / required=false / caps blip ⇒ pass (fail-open + audit). `allowedLanes` is an
62
104
  * OPEN domain — unknown names simply never match this worker's lane.
105
+ *
106
+ * `logger` (optional, defaults to this module's `defaultLogger`) is an injection seam purely for the
107
+ * once-per-process default trace below (test observability) — it does not change enforcement.
63
108
  */
64
- export function gateExecutionLane(ruling, ownLane) {
65
- if (!ruling?.required)
109
+ export function gateExecutionLane(ruling, ownLane, logger = defaultLogger) {
110
+ if (!ruling) {
111
+ traceExecutionLaneDefault("no_ruling", ownLane, logger);
66
112
  return;
113
+ }
114
+ if (!ruling.required) {
115
+ traceExecutionLaneDefault("required_false", ownLane, logger);
116
+ return;
117
+ }
67
118
  if (!ruling.allowedLanes.includes(ownLane)) {
68
119
  throw new HttpError(403, `this worker's execution lane "${ownLane}" is not permitted for this principal (execution policy)`, {
69
120
  code: "execution_lane_not_allowed",
@@ -149,7 +200,18 @@ export function createPrincipalEntitlementsClient(opts) {
149
200
  return entry;
150
201
  }
151
202
  catch (err) {
152
- const e = err instanceof Error ? err : new Error(String(err));
203
+ // codex 交叉复审第三轮真发现(2026-08-05):a poison rejection reason (throwing `Symbol.toPrimitive`/
204
+ // `toString`) must not make THIS normalization line itself throw — this catch block exists to
205
+ // GUARANTEE the documented FAIL-CLOSED degrade below runs; if normalizing `err` throws, that
206
+ // guarantee is exactly what breaks (resolveRuntimeCaps/scenarioRuling/executionRuling would reject
207
+ // instead of resolving to their fail-closed/fail-open defaults on a degraded center response).
208
+ let e;
209
+ try {
210
+ e = err instanceof Error ? err : new Error(String(err));
211
+ }
212
+ catch {
213
+ e = new Error("center fetch failed with an unstringifiable rejection reason");
214
+ }
153
215
  // Caps face FAIL CLOSED: deny the OPTIONAL amplification caps (workflows + fork); never force the durable
154
216
  // gate on error. Scenario face FAIL OPEN: `scenario` stays undefined (no governance — never pin users to
155
217
  // the default scenario on a center blip; deliberate posture split, see PrincipalEntitlementsClient). Cache the
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Sema-registry adapter — pull the effective config from sema-registry and apply it OVER the
3
+ * env-derived defaults (env = fallback, center = override). Follows the "universal internal
4
+ * schema → translate at the boundary" pattern, with our security boundary kept:
5
+ *
6
+ * - the CENTER owns the LOGICAL config: the model roster (names/capabilities/tier), the role map,
7
+ * and team templates;
8
+ * - the SERVICE env still owns the SECRETS: API keys stay in env (the center never stores a secret);
9
+ * keys are per-model via `apiKeyEnv` → `config.modelApiKeyEnv` → the spec's `getApiKeyAndHeaders`
10
+ * (core 1.45), so each model/cascade-rung authenticates with its own upstream key.
11
+ *
12
+ * Per-model `baseUrl` IS transported (a catalog model may live on a different gateway;
13
+ * core brain honors model.baseUrl, absent = "" = boot-env endpoint).
14
+ *
15
+ * Hot-reload status (复审 2026-07-29 P1-11 — 亲读判定,取代此处旧的 "models/roles are restart-to-apply"
16
+ * TODO, which went stale when `mutateInPlace` landed):
17
+ * - models/roles/roster/teams/projects/autonomy — **HOT**. `applyEffective` mutates `config.models`/
18
+ * `config.roles` IN PLACE, and core's Runner reads `this.deps.models/roles` per task off that very
19
+ * reference. Both brains re-resolve `model.baseUrl || config.baseUrl` inside `buildRequest()` on every
20
+ * call (core 2.1.0 `brain/openai.js` + `brain/anthropic.js`), so a moved gateway takes effect on the
21
+ * next task with no restart.
22
+ * - the same plane under a **tier-frozen** Runner — deferred, not hot: core expands a PRIVATE catalog copy
23
+ * at construction, so `main` defers the whole plane and the `models-tiers` restart slice signals.
24
+ * - **`degrade-route`** — the one genuinely boot-frozen catalog consumer left: reactive degrade
25
+ * (`MODEL_DEGRADE_REACTIVE`) bakes the target Model + its gateway + its key into the brain composition
26
+ * at boot. It cannot be hot-applied without rebuilding the brain, so it is registered on the existing
27
+ * restartRequired /health contract instead (see `restart-signal.ts`). Fail-loud beats serving a stale
28
+ * gateway silently on the rate-limit path.
29
+ * - skills/mcp/scenarios/runtime-gates — restart-to-apply by construction (baked into the boot wiring).
30
+ *
31
+ * (design/158 A13, internal-lossless) This module is now a FACADE: the implementation lives in
32
+ * `src/config-center/` split by responsibility group (HTTP client / EffectiveConfig application /
33
+ * restart-signal detection / skills+MCP consumption). Every symbol below is re-exported UNCHANGED —
34
+ * existing `from "./sema-registry.js"` / `from "../sema-registry.js"` imports need zero changes.
35
+ */
36
+ export { fetchEffective, fetchPrincipalCaps, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./config-center/http-client.js";
37
+ export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, runtimeHasActiveGate, } from "./config-center/apply-effective.js";
38
+ export { restartReasons, planeHasActiveTiers, modelPlaneChanged, type RestartSlice, type RestartSliceCtx, type RestartSignal, } from "./config-center/restart-signal.js";
39
+ export { applyCenterSkills, resolveMcpServers, mcpForScenario } from "./config-center/skills-mcp.js";
40
+ export type { CenterSkillManifest, CenterMcpServer, EffectiveConfig, ExecutionRuling, SessionMirrorRuling, } from "./config-center/types.js";
41
+ //# sourceMappingURL=sema-registry.d.ts.map