@sema-agent/server 7.109.0 → 7.110.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 (80) hide show
  1. package/MIGRATION.md +17 -0
  2. package/README.md +8 -0
  3. package/README.zh-CN.md +5 -0
  4. package/USAGE.md +2 -2
  5. package/dist/approval-card.d.ts +6 -5
  6. package/dist/approval-card.js +8 -11
  7. package/dist/boot/reapers.d.ts +3 -0
  8. package/dist/boot/reapers.js +3 -2
  9. package/dist/boot/resolve-spec.js +3 -4
  10. package/dist/boot/stage-07-capability-layer.d.ts +1 -0
  11. package/dist/boot/stage-07-capability-layer.js +17 -3
  12. package/dist/boot/stage-08-reapers.d.ts +1 -0
  13. package/dist/boot/stage-08-reapers.js +2 -2
  14. package/dist/boot/stage-09-leader.d.ts +1 -0
  15. package/dist/boot/stage-10-http-server.d.ts +1 -0
  16. package/dist/boot/stage-10-http-server.js +2 -1
  17. package/dist/config-catalog.js +1 -1
  18. package/dist/config-types.d.ts +3 -2
  19. package/dist/config.js +19 -7
  20. package/dist/deployment-governance.d.ts +8 -2
  21. package/dist/deployment-governance.js +9 -8
  22. package/dist/hooks/hook-runner.d.ts +2 -2
  23. package/dist/hooks/hook-runner.js +40 -26
  24. package/dist/http/durable-run-leg.js +1 -1
  25. package/dist/http/resume-legs.js +6 -1
  26. package/dist/http/routes/runs.d.ts +0 -1
  27. package/dist/http/routes/runs.js +22 -0
  28. package/dist/http/routes/sessions.js +12 -2
  29. package/dist/http/routes/tasks.js +5 -1
  30. package/dist/http/server.d.ts +5 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +1 -0
  33. package/dist/instruction-source-notice.d.ts +3 -3
  34. package/dist/memory-layer-legacy-lock.d.ts +3 -3
  35. package/dist/model-entry-headers.d.ts +5 -2
  36. package/dist/model-entry-headers.js +4 -4
  37. package/dist/model-unreachable-notice.d.ts +2 -2
  38. package/dist/observability/corrupt-read-seat.d.ts +6 -0
  39. package/dist/observability/corrupt-read-seat.js +6 -0
  40. package/dist/observability/fail-open.d.ts +13 -1
  41. package/dist/observability/fail-open.js +13 -1
  42. package/dist/observability/metrics.js +2 -1
  43. package/dist/observability/run-terminal.js +10 -0
  44. package/dist/operator-ask.d.ts +9 -8
  45. package/dist/operator-ask.js +9 -1
  46. package/dist/plugins/file-run-store.js +2 -2
  47. package/dist/plugins/memory-run-store.d.ts +1 -1
  48. package/dist/plugins/memory-run-store.js +2 -1
  49. package/dist/plugins/pg-pool.js +15 -0
  50. package/dist/plugins/run-store-sql.d.ts +1 -1
  51. package/dist/plugins/run-store-sql.js +3 -2
  52. package/dist/plugins/schema-indexes.js +2 -0
  53. package/dist/plugins/store-backend.d.ts +4 -0
  54. package/dist/plugins/store-backend.js +8 -0
  55. package/dist/plugins/store-contracts.d.ts +7 -0
  56. package/dist/plugins/store-contracts.js +1 -0
  57. package/dist/plugins/tidb-pool.js +17 -0
  58. package/dist/plugins/user-input-ledger-file.d.ts +33 -0
  59. package/dist/plugins/user-input-ledger-file.js +98 -0
  60. package/dist/plugins/user-input-ledger-sql.d.ts +44 -0
  61. package/dist/plugins/user-input-ledger-sql.js +101 -0
  62. package/dist/run-local.d.ts +18 -5
  63. package/dist/run-local.js +24 -9
  64. package/dist/runs.d.ts +8 -1
  65. package/dist/runs.js +6 -2
  66. package/dist/runtime-governance.d.ts +20 -8
  67. package/dist/runtime-governance.js +7 -12
  68. package/dist/session-records-skipped-notice.d.ts +4 -3
  69. package/dist/task-settings.d.ts +0 -6
  70. package/dist/task-settings.js +0 -1
  71. package/dist/tool-approval.d.ts +8 -1
  72. package/dist/tool-approval.js +4 -1
  73. package/dist/trace/core-keyset-guard.d.ts +16 -1
  74. package/dist/trace/engine-notice-wire.d.ts +30 -35
  75. package/dist/trace/engine-notice-wire.js +23 -23
  76. package/dist/trace/self-minted-wire.d.ts +14 -0
  77. package/dist/trace/self-minted-wire.js +6 -0
  78. package/dist/user-input-ledger.d.ts +296 -0
  79. package/dist/user-input-ledger.js +337 -0
  80. package/package.json +3 -3
package/dist/run-local.js CHANGED
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
10
10
  import { Runner, uuidv7, FileStorageBackend, NodeExecutionEnv, TtlSessionStore, CenterPromptSource, FilePromptArtifactStore, FilePromptSourceStateStore, MemoryPromptArtifactStore, MemoryPromptSourceStateStore, defaultTaskRegistry } from "@sema-agent/core";
11
11
  import { singleUserAutoAcceptBaselineApplies } from "./approval.js";
12
12
  import { withUnattendedLeg } from "./unattended-permission-mode.js";
13
+ import { buildUnattendedPolicyRefusal } from "./tool-approval.js";
13
14
  import { createBrain } from "./brain.js";
14
15
  import { assertGuardPatternsUsable, buildOnlySensitiveBaselineWarning, createApprovalBaselinePolicy, createDeploymentGovernanceInputs, } from "./deployment-governance.js";
15
16
  import { applyRuntimeGovernance } from "./runtime-governance.js";
@@ -130,8 +131,14 @@ const RUN_LOCAL_APPROVAL_SEAT = { kind: "live" };
130
131
  export function applyLocalGovernance(base, config, workspaceDir, engineDataRoot) {
131
132
  return applyRuntimeGovernance({ ...base, toolPolicy: createApprovalBaselinePolicy(config, RUN_LOCAL_APPROVAL_SEAT) }, createDeploymentGovernanceInputs(config, { env: new NodeExecutionEnv({ cwd: workspaceDir }), cwd: workspaceDir }, { engineDataRoot }));
132
133
  }
134
+ const NO_TTY_REFUSAL_REASON = "this run-local invocation has no TTY, so there is no one to put this approval to; it is refused fail-closed instead of being asked.";
135
+ const UNDISPLAYABLE_ARGS_REFUSAL_REASON = "the call's arguments cannot be displayed, so there is nothing a person could review; it is refused fail-closed instead of being asked.";
133
136
  export function createLocalApprover(io) {
134
137
  let tail = Promise.resolve();
138
+ const refuseWithoutPerson = (reason) => {
139
+ io.noteRefusedWithoutPerson();
140
+ return buildUnattendedPolicyRefusal(reason);
141
+ };
135
142
  return async (req, signal) => {
136
143
  if (!io.isTty) {
137
144
  io.printErr(`denied "${terminalSafeText(req.toolName)}" (${terminalSafeText(req.message)}) — this run-local invocation has no TTY, so there is nobody to ask. ` +
@@ -139,13 +146,13 @@ export function createLocalApprover(io) {
139
146
  `APPROVAL_DENY refuses outright without asking), AUTONOMY, MANUAL_MODE_SHELL_GATE, runtime.commandPolicy (config.d/governance.json). ` +
140
147
  `Run it in a terminal to answer interactively, or relax the knob (APPROVAL_AUTO_BUDGET auto-approves that many APPROVAL_REQUIRE calls; ` +
141
148
  `a separate .env for this CLI keeps a server's lists off it).`);
142
- return false;
149
+ return refuseWithoutPerson(NO_TTY_REFUSAL_REASON);
143
150
  }
144
151
  const argsShown = argsDisplayOf(req.args);
145
152
  if (!argsShown.ok) {
146
153
  io.printErr(`denied "${terminalSafeText(req.toolName)}" (${terminalSafeText(req.message)}) — its arguments cannot be displayed ` +
147
154
  `(${terminalSafeText(argsShown.reason)}), so there is nothing a person could review; refusing instead of asking.`);
148
- return false;
155
+ return refuseWithoutPerson(UNDISPLAYABLE_ARGS_REFUSAL_REASON);
149
156
  }
150
157
  const prior = tail;
151
158
  let release;
@@ -221,24 +228,28 @@ export async function promptYesNoOnTty(prompt, signal, streams = { input: proces
221
228
  rl.close();
222
229
  }
223
230
  }
224
- function personStopOf(result) {
231
+ function unfinishedStopOf(result, refusedWithoutPerson) {
225
232
  if (result.terminal.kind !== "completed")
226
233
  return undefined;
227
234
  if (result.haltedOnUserRejection === true)
228
235
  return "haltedOnUserRejection";
229
236
  if (result.haltedByUser === true)
230
237
  return "haltedByUser";
238
+ if (refusedWithoutPerson > 0)
239
+ return "approvalUnanswered";
231
240
  return undefined;
232
241
  }
233
- function buildPersonStopFooter(stop, sessionId) {
242
+ function buildUnfinishedStopFooter(stop, sessionId) {
234
243
  switch (stop) {
235
244
  case "haltedOnUserRejection":
236
245
  return `task stopped (haltedOnUserRejection): a tool call was refused, so the run did not finish its work — continue it with --session ${sessionId}`;
237
246
  case "haltedByUser":
238
247
  return `task stopped (haltedByUser): the run was halted before it finished its work — continue it with --session ${sessionId}`;
248
+ case "approvalUnanswered":
249
+ return `task incomplete (approval unanswered): a tool call needed an approval that no person could give in this invocation (see the "denied" lines above), so the run did not finish its work — continue it with --session ${sessionId}`;
239
250
  default: {
240
251
  const unhandled = stop;
241
- throw new Error(`person-stop marker "${String(unhandled)}" has no footer`);
252
+ throw new Error(`unfinished-stop word "${String(unhandled)}" has no footer`);
242
253
  }
243
254
  }
244
255
  }
@@ -421,10 +432,14 @@ export async function runLocal(argv, deps = {}) {
421
432
  catch (e) {
422
433
  logger.warn("center_prompts_catalog_restore_failed", { err: String(e), note: "catalog candidate absent — built-ins serve" });
423
434
  }
435
+ let refusedWithoutPerson = 0;
424
436
  const onAsk = createLocalApprover({
425
437
  isTty: deps.isTty ?? process.stdin.isTTY === true,
426
438
  askYesNo: deps.askYesNo ?? promptYesNoOnTty,
427
439
  printErr,
440
+ noteRefusedWithoutPerson: () => {
441
+ refusedWithoutPerson++;
442
+ },
428
443
  });
429
444
  const engineOnError = (err, ctx) => {
430
445
  if (ctx.phase === "memory") {
@@ -622,14 +637,14 @@ export async function runLocal(argv, deps = {}) {
622
637
  await fileBackend.dispose();
623
638
  return 1;
624
639
  }
625
- const personStop = personStopOf(result);
640
+ const unfinishedStop = unfinishedStopOf(result, refusedWithoutPerson);
626
641
  if (args.json) {
627
642
  print(JSON.stringify(result, null, 2));
628
643
  }
629
644
  else if (result.terminal.kind === "completed") {
630
645
  print(result.result);
631
- if (personStop !== undefined)
632
- printErr(buildPersonStopFooter(personStop, sessionId));
646
+ if (unfinishedStop !== undefined)
647
+ printErr(buildUnfinishedStopFooter(unfinishedStop, sessionId));
633
648
  }
634
649
  else {
635
650
  const plane = outwardPlane(result.terminal);
@@ -638,7 +653,7 @@ export async function runLocal(argv, deps = {}) {
638
653
  print(salvage);
639
654
  printErr(`task ${plane.status}${plane.errorCode ? ` (${plane.errorCode})` : ""}${plane.errorMessage ? `: ${plane.errorMessage}` : ""}`);
640
655
  }
641
- if (result.terminal.kind !== "completed" || personStop !== undefined)
656
+ if (result.terminal.kind !== "completed" || unfinishedStop !== undefined)
642
657
  exitCode = 1;
643
658
  if (memorySync) {
644
659
  try {
package/dist/runs.d.ts CHANGED
@@ -9,6 +9,7 @@ import { type ToolApprovalCoordinator } from "./tool-approval.js";
9
9
  import { type FleetRunPublisher } from "./fleet/fleet-bus.js";
10
10
  import { type WorkflowCompletionInbox } from "./orchestration/workflow-completion-inbox.js";
11
11
  import type { VerifyRoundsSpec } from "./http/verify-rounds.js";
12
+ import type { UserInputLedger } from "./user-input-ledger.js";
12
13
  /**
13
14
  * stoppedBy (core 1.252): a service cancel is a USER stop — mark the run's still-running background
14
15
  * children BEFORE core's teardown reaps them (bare abort = attribution falls back to "system"; core's own
@@ -368,7 +369,13 @@ pendingEngineNotices?: readonly {
368
369
  * `TrustedRunInternals` 的白名单会在编译期先红一次(它只开部署方可以决定的那一格)。
369
370
  * 缺席 ⇒ 座席与本位存在之前逐字相同。
370
371
  */
371
- trustedInternals?: Pick<RunInternals, "workflowParkedResume">): Promise<void>;
372
+ trustedInternals?: Pick<RunInternals, "workflowParkedResume">,
373
+ /**
374
+ * S-928 用户输入受理账本(`src/user-input-ledger.ts`;路由层 `deps.userInputLedger`)。两手:开通告口那一拍(模型调用之前)把本会话
375
+ * 已收敛、未告知的崩溃丢失铸成 `task.user_steer_undrained{cause:"crashed"}`;腿收场时把本腿上的 steer 受理 settle 掉。
376
+ * 缺席 ⇒ 两手都不做(与本参数存在之前逐字相同)。
377
+ */
378
+ userInputLedger?: UserInputLedger): Promise<void>;
372
379
  /** How often a running instance refreshes its run's updated_at (liveness, independent of events).
373
380
  * Must stay strictly below `runStaleSec` (asserted at startup AND on every hot config candidate) or the
374
381
  * reaper would race live runs. [ref]:数值的单源是 `config-invariants.ts`(那条不变量的属主),两个消费面
package/dist/runs.js CHANGED
@@ -173,7 +173,7 @@ export function evictIfConflict(runner, sessionId, result) {
173
173
  runner.sessions.evict?.(sessionId);
174
174
  }
175
175
  }
176
- export async function runInBackground(runner, spec, runStore, taskId, metrics, principal, verify, cascade, instrumentDegenerate, planCacheProbe, persistThinking = false, inflight, preemptable, steerable, modelUsage, elicitation, elicitOwner, captureTurnAnchor, fleetPublisher, captureUserMessageAnchor, question, workflowCompletionInbox, subagentSteer, completionDiagLog, sendUserFile, promptManifests, approval, parkCheckpointReader, pendingEngineNotices, trustedInternals) {
176
+ export async function runInBackground(runner, spec, runStore, taskId, metrics, principal, verify, cascade, instrumentDegenerate, planCacheProbe, persistThinking = false, inflight, preemptable, steerable, modelUsage, elicitation, elicitOwner, captureTurnAnchor, fleetPublisher, captureUserMessageAnchor, question, workflowCompletionInbox, subagentSteer, completionDiagLog, sendUserFile, promptManifests, approval, parkCheckpointReader, pendingEngineNotices, trustedInternals, userInputLedger) {
177
177
  const startedAt = Date.now();
178
178
  metrics?.addGauge("runs_active", 1);
179
179
  fleetPublisher?.onStart();
@@ -270,9 +270,11 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
270
270
  const unregisterEngineNotice = registerEngineNoticeLeg({
271
271
  sessionId: spec.sessionId,
272
272
  ...(pendingEngineNotices !== undefined ? { pending: pendingEngineNotices } : {}),
273
- durable: (row) => void append("engine_notice", row).catch(() => recordFailOpen("server.engine-notice.durable-append-failed", `leg=bg code=${row.code} task=${taskId}`)),
273
+ durable: (row) => append("engine_notice", row).then(() => true, () => (recordFailOpen("server.engine-notice.durable-append-failed", `leg=bg code=${row.code} task=${taskId}`), false)),
274
274
  });
275
275
  try {
276
+ if (userInputLedger && spec.sessionId)
277
+ await userInputLedger.announceUndrained({ sessionId: spec.sessionId, taskId });
276
278
  await emitPendingWorkflowCompletions(workflowCompletionInbox, spec.sessionId, elicitOwner ?? owner, (frame) => {
277
279
  const { type, ...rest } = frame;
278
280
  const f = frame;
@@ -538,6 +540,8 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
538
540
  ...(spec.sessionId ? { transcriptId: spec.sessionId } : {}),
539
541
  });
540
542
  }
543
+ if (steerableStreamRef !== undefined)
544
+ await userInputLedger?.settleLeg(steerableStreamRef);
541
545
  if (inflight?.get(taskId) === cancelCtrl)
542
546
  inflight.delete(taskId);
543
547
  if (preemptable?.get(taskId) === preemptCtrl)
@@ -61,11 +61,11 @@ export declare const COARSE_SHELL_TOOLS_MIRROR: readonly string[];
61
61
  * OK). Called at config-apply (config-center/facade.ts `applyRuntimeHot`) so a malformed rule that BYPASSED the
62
62
  * registry schema (a hand-edited config.d / a non-conformant publish) is rejected LOUDLY rather than
63
63
  * silently mis-compiling — review MEDIUM: an out-of-enum `decision` (e.g. "DENY") would otherwise fall through
64
- * {@link compileCommandPolicy}'s `decision==="deny"?…:"ask"` to ASK, silently WEAKENING an intended deny. */
64
+ * {@link compileCommandPolicyLayers}'s `decision==="deny"?…:"ask"` to ASK, silently WEAKENING an intended deny. */
65
65
  export declare function validateCommandRules(rules: CommandRule[]): string[];
66
66
  /**
67
- * Compile `runtime.commandPolicy` (per-command 3-way rules) into a single ToolPolicy, or `undefined` when there
68
- * is nothing to govern. The registry model (per-command `allow`/`ask`/`deny`) and core's helper model
67
+ * Compile `runtime.commandPolicy` (per-command 3-way rules) into the command gate's LEAF policies (`[]` when there
68
+ * is nothing to govern). The registry model (per-command `allow`/`ask`/`deny`) and core's helper model
69
69
  * (`allow[]` + `deny[]` + `defaultAction`) don't map 1:1, so the compilation picks a mode by the rules present:
70
70
  *
71
71
  * - **Allowlist mode** — triggered when ANY rule resolves to `allow`. Only `allow` commands pass; `deny` wins;
@@ -81,7 +81,12 @@ export declare function validateCommandRules(rules: CommandRule[]): string[];
81
81
  * strictest-wins (without this, the `allow` rule would shadow the `ask` for that command in allowlist mode).
82
82
  *
83
83
  * Either way the result only ever ADDS asks/denies — composed onto the baseline via `combinePolicies`
84
- * (deny-wins) by {@link applyRuntimeGovernance}, so it is tighten-only. (Assumes commands are pre-validated by
84
+ * (deny-wins) by {@link applyRuntimeGovernance}, so it is tighten-only.
85
+ *
86
+ * 🔴 **S-958:返回叶,不在这里折**(改名自 `compileCommandPolicy`,修前返回 blocklist 形里 `combinePolicies(coarseDeny, askList)` 的
87
+ * 一只折叠)。复合命令(`a && b`、管道、`;`)上两只叶都按「解析不了 ⇒ 问」问 ⇒ 那只折叠带出 `foldedAskClasses`,而治理层随后要给每只
88
+ * ask 盖部署出身 —— 盖在折叠的输出上就是展开一只 core 担保过身份的对象(core #1266 7.39.0 按名拒)。叶交出去,由
89
+ * {@link applyRuntimeGovernance} 逐叶盖位、折**一次**:本函数不知道出身这件事,治理层只有一个折叠点。 (Assumes commands are pre-validated by
85
90
  * {@link validateCommandRules}; an un-matchable command here is simply inert, never a parse error.)
86
91
  *
87
92
  * 🔴 **S-496:词表外的 `decision` 折 `deny`,不折 `ask`。** {@link validateCommandRules} 的头注写下的那条
@@ -91,7 +96,7 @@ export declare function validateCommandRules(rules: CommandRule[]): string[];
91
96
  * 收口:`VALID_DECISIONS` 之外的值一律折最严的 `deny`(fail-closed,[ref] 的运行期 miss 臂,下方自陈),
92
97
  * 闭集本身则由契约 zod 派生 ⇒ 契约加一个判词,`RANK` 与桶分配的穷尽 switch 双双**编译红**。
93
98
  */
94
- export declare function compileCommandPolicy(rules: CommandRule[] | undefined): ToolPolicy | undefined;
99
+ export declare function compileCommandPolicyLayers(rules: CommandRule[] | undefined): ToolPolicy[];
95
100
  /**
96
101
  * Expand an autonomy mode into TIGHTEN-ONLY `TaskSpec` safety overrides (the primitives — TRAP #2):
97
102
  * - `read-only` / `plan` → `handsReadOnly: true` **+ `writeFace: "roots"`** (read + propose, never mutate; the
@@ -216,20 +221,27 @@ export declare function governanceOriginOf(gate: {
216
221
  * 本仓的类级门是 test/operator-knob-client-posture-matrix.test.ts 的 旋钮×表态×lane 矩阵):
217
222
  * `SENSITIVE_WRITE_PATTERNS` 是 [ref] 那次搬家漏下的兄弟旋钮——同一个病灶原样复发在 sensitive-path
218
223
  * DENY 腿上(唯一合成点在 deriveSettingsPolicy 的 fsWriteGate 闭包,bypassPermissions/键缺席/settings
219
- * 整缺席三形整条腿不建)。同方修:调用方(resolve-spec)把 operator 守卫集预铸成 `sensitivePathPolicy`
220
- * 从这里**无条件施加**——它只在守卫段上产 deny(core 语义 deny/"safety"),对其余目标返回的是
224
+ * 整缺席三形整条腿不建)。同方修:调用方(resolve-spec)把 operator 守卫集预铸成 `sensitivePathLayers`(叶;S-958 起不再是
225
+ * 一只预折的策略,见下段)从这里**无条件施加**——它只在守卫段上产 deny(core 语义 deny/"safety"),对其余目标返回的是
221
226
  * `action:"allow"`(**不是**「无意见」这种第三态:core 的 ToolPolicy 没有弃权值。它在
222
227
  * `combinePolicies` 的 deny/ask 优先折叠里不构成一票,所以在**折叠语境**下等价于弃权;若日后有人把
223
228
  * 这条策略单独当成唯一 policy 用,那个 allow 就是真放行——别照抄这句话去别的语境)。于是对 bypass
224
229
  * 只挡守卫段、不给普通目标加 ask 门([ref] 表第四行「bypass=不加门非开门」保持逐字)。策略在这里
225
230
  * 只组合不构造:裁决 env 的 lane 分形(host 真 fs / 沙箱 deferred 代理)是 resolve-spec 的属主知识,
226
231
  * governance 层不重复它。
232
+ *
233
+ * 🔴 **S-958(core #1266 7.39.0)—— 治理层只有一个折叠点**:两种输入都交**叶**(`compileCommandPolicyLayers` 的那几只、
234
+ * `sensitivePathLayers` 的那几只),本函数对每一片叶各盖一次部署出身(`createDeploymentAuthoredPolicy`),再交 core
235
+ * `combinePolicies` 折一次;折叠是最外层,输出经标记观察器原物交回。修前是「先折、再在折叠输出上展开盖位」——折叠带出
236
+ * `foldedAskClasses` 时交给 core 的是副本,7.39.0 起按名拒(工具不跑、无卡)。
227
237
  */
228
238
  export declare function applyRuntimeGovernance(base: TaskSpec, governance: {
229
239
  autonomy?: Autonomy;
230
240
  commandPolicy?: CommandRule[];
231
241
  manualModeShellGate?: ShellGateDoctrine;
232
- sensitivePathPolicy?: ToolPolicy;
242
+ /** 守卫集的**叶**(`deployment-governance.ts` `createDeploymentGovernanceInputs` 预铸;S-958 起是叶的列表,不是一只预折的策略)。
243
+ * 空列表与缺席同义。 */
244
+ sensitivePathLayers?: readonly ToolPolicy[];
233
245
  /** [ref]/[ref] `governanceForced` 的标记表(缺省 = **当前 run 腿的 ALS 表**,见
234
246
  * `governance-ask-marks.ts` 顶注「作用域」段)。注入口只为测试与将来的多实例形。 */
235
247
  askMarks?: GovernanceAskMarks;
@@ -1,7 +1,7 @@
1
1
  import { combinePolicies, COARSE_SHELL_TOOLS as CORE_COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, DEFAULT_SUBAGENT_TOOL_NAME, parseLeadingCommandName, tightenTaskSpec, wrapToolPolicy, } from "@sema-agent/core";
2
2
  import { GovernanceConfig } from "@sema-agent/settings-schema";
3
3
  import { currentGovernanceAskMarks } from "./governance-ask-marks.js";
4
- import { operatorAsk, withDeploymentAuthorship } from "./operator-ask.js";
4
+ import { createDeploymentAuthoredPolicy, operatorAsk } from "./operator-ask.js";
5
5
  import { heldRuleName } from "./tool-rule-names.js";
6
6
  const COMMAND_RULE_SHAPE = GovernanceConfig.shape.commandPolicy.unwrap().element.shape;
7
7
  const COMMAND_DECISIONS = COMMAND_RULE_SHAPE.decision.options;
@@ -57,9 +57,9 @@ export function validateCommandRules(rules) {
57
57
  }
58
58
  return errors;
59
59
  }
60
- export function compileCommandPolicy(rules) {
60
+ export function compileCommandPolicyLayers(rules) {
61
61
  if (!rules || rules.length === 0)
62
- return undefined;
62
+ return [];
63
63
  const RANK = { deny: 3, ask: 2, allow: 1 };
64
64
  const strictest = new Map();
65
65
  for (const r of rules) {
@@ -98,9 +98,7 @@ export function compileCommandPolicy(rules) {
98
98
  if (ask.length > 0)
99
99
  policies.push(createCommandAskListPolicy(ask));
100
100
  }
101
- if (policies.length === 0)
102
- return undefined;
103
- return policies.length === 1 ? policies[0] : combinePolicies(...policies);
101
+ return policies;
104
102
  }
105
103
  export function autonomyOverrides(autonomy) {
106
104
  switch (autonomy) {
@@ -125,7 +123,7 @@ export function governanceOriginOf(gate, governance) {
125
123
  function createGovernanceAskMarkingPolicy(inner, marksOf) {
126
124
  const note = (d, req) => {
127
125
  marksOf()?.setMarked(req.toolCallId, d.action === "ask");
128
- return d.action === "ask" ? withDeploymentAuthorship(d) : d;
126
+ return d;
129
127
  };
130
128
  return wrapToolPolicy(inner, (req, signal) => {
131
129
  const decision = inner.check(req, signal);
@@ -145,11 +143,8 @@ function createGovernanceShellGateMarkPolicy(marksOf, tools = COARSE_SHELL_TOOLS
145
143
  export function applyRuntimeGovernance(base, governance) {
146
144
  const marksOf = () => governance.askMarks ?? currentGovernanceAskMarks();
147
145
  const overrides = { ...autonomyOverrides(governance.autonomy) };
148
- const coarse = compileCommandPolicy(governance.commandPolicy);
149
- const governancePolicies = [coarse, governance.sensitivePathPolicy].filter((p) => p !== undefined);
150
- const governancePolicy = governancePolicies.length > 0
151
- ? createGovernanceAskMarkingPolicy(governancePolicies.length === 1 ? governancePolicies[0] : combinePolicies(...governancePolicies), marksOf)
152
- : undefined;
146
+ const layers = [...compileCommandPolicyLayers(governance.commandPolicy), ...(governance.sensitivePathLayers ?? [])];
147
+ const governancePolicy = layers.length > 0 ? createGovernanceAskMarkingPolicy(combinePolicies(...layers.map(createDeploymentAuthoredPolicy)), marksOf) : undefined;
153
148
  if (governancePolicy !== undefined)
154
149
  overrides.toolPolicy = governancePolicy;
155
150
  if (governance.manualModeShellGate === "always")
@@ -1,6 +1,7 @@
1
- import type { FileStorageCorruptReadInfo, SessionRepo } from "@sema-agent/core";
2
- import { type EngineNoticeRouter, type ServerNoticeCode } from "./trace/engine-notice-wire.js";
3
- export declare const SESSION_RECORDS_SKIPPED: ServerNoticeCode;
1
+ import type { EngineNoticeCode, FileStorageCorruptReadInfo, SessionRepo } from "@sema-agent/core";
2
+ import { type EngineNoticeRouter } from "./trace/engine-notice-wire.js";
3
+ /** 码常量型 = core 码册(#1240 入册:受众 user、铸者 `NOTICE_MINTER` = `"server"`)。 */
4
+ export declare const SESSION_RECORDS_SKIPPED: EngineNoticeCode;
4
5
  /**
5
6
  * 披露座上的「作用域在场则记」腿(装配点 = `plugins/store-backend.ts` 里喂给 `LocalSessionStore` 的那只
6
7
  * `FileSessionRepo` 的 `onCorruptRead`)。只认 `kind:"replay"` 这一级(`record` 由它汇总,`file` 没有会话归属)。
@@ -137,12 +137,6 @@ export declare function effectiveThinking(reasoningEffort: unknown, ultracode: b
137
137
  * field is the explicit per-turn intent → it WINS over a bundle defaultMode). Creates a minimal settings object when
138
138
  * no `body.settings` bundle was sent. The result flows through {@link applyTaskSettings} (tighten-only). */
139
139
  export declare function withPermissionMode(settings: ParsedTaskSettings | undefined, mode: SettingsPermissionMode): ParsedTaskSettings;
140
- /**
141
- * 7.106.0 合并复审 X8(opus O-6)—— 请求**没有表态**模式时引擎实际跑的那个词。core 7.34.0 [ref]「Absent = `default`」
142
- * (装树 `dist/core/runner/prepare-task.js` 的 `requested ?? "default"`)。只给**观测面**用(hook 载荷的 `permission_mode`,CC 恒带这个键):
143
- * spec 过境点照旧不写键(`effectivePermissionMode` 缺席 ⇒ 不替调用方选 —— 那是引擎的默认,不是本仓的表态)。
144
- */
145
- export declare const ENGINE_ABSENT_PERMISSION_MODE: "default";
146
140
  /**
147
141
  * [ref] §6-1 —— 本请求的**生效** permission mode,单点。优先序逐字同 {@link withPermissionMode}:
148
142
  * 顶层 `body.permissionMode`(本轮显式表态)赢过 bundle 的 `settings.permissions.defaultMode`;两处
@@ -47,7 +47,6 @@ export function withPermissionMode(settings, mode) {
47
47
  const base = settings ?? {};
48
48
  return { ...base, permissions: { ...base.permissions, defaultMode: mode } };
49
49
  }
50
- export const ENGINE_ABSENT_PERMISSION_MODE = "default";
51
50
  export function effectivePermissionMode(body, settings) {
52
51
  const requested = coercePermissionMode(body.permissionMode) ?? settings?.permissions?.defaultMode;
53
52
  const kill = settings?.permissions?.disableAutoMode;
@@ -288,7 +288,7 @@ export interface ToolApprovalFrame {
288
288
  /**
289
289
  * **S-934 ②(core 7.37.0 #1214;ADDITIVE,`"tool_approval"` only)** —— 这一问的**类**:`classes` 的首员(core `ASK_CLASSES` 序),
290
290
  * `MODE_ARBITRATION` 的行键(权限模式读的就是它 / 它们)。引擎在每一个铸 `origin` 的站盖章(策略自报的值在盖章处被替换),本仓经
291
- * `approval-card.ts` 的 `readAskClassPair(req)` 结构窄读(成员筛 = core `isAskClass`,本仓不抄词表;在场却读不出 ⇒ 两键都不投 + 计一次
291
+ * `approval-card.ts` 的 `readAskClassPair(req)` 窄读(判法 = core 屏 `screenAskClassMembers`,S-955 ⑥ / #1249,本仓零自写对判;屏拒 ⇒ 两键都不投 + 计一次
292
292
  * S-249 集外 tag)。**与 {@link classes} 同在同缺**;缺席 = 老引擎 / 不盖类的站(共享制品读确认,core KL-1214-a),不是「无类」。
293
293
  * display:壳按类渲卡(cli 卡面、client-core CC-272),durable 审批席按类答;**本仓不按它裁决**(无人值守腿的按类作答读 core 交来的
294
294
  * `AskRequest.classes` 本身)。同一对的其余面:`card_json` 卡面 / `approval_request` 呈卡帧 / 断连重放帧(同一份素材)、运维队列行
@@ -838,6 +838,13 @@ export type UnattendedPolicyRefusal = Extract<AskOutcome, {
838
838
  settledBy: Extract<HostSettledBy, "policy">;
839
839
  reason: string;
840
840
  };
841
+ /**
842
+ * {@link UnattendedPolicyRefusal} 的**唯一**构造(纯数据 ⇒ `build*`)。来源词读店落行的**同一个**常量(`POLICY_REFUSAL_DECISION`,NP-2):
843
+ * 行上记的与交给 core 的在构造上是一个词。两个铸点读它:协调器 deny 政策的「无人可答」({@link ToolApprovalCoordinator.unattendedOutcome})
844
+ * 与 run-local 本地席没有人可问的两臂(S-956:无 TTY / 参数展示不出 —— `src/run-local.ts` `createLocalApprover`)。`reason` = 铸点的
845
+ * 闭集拒因句(core 以 `policy note` 围栏交给模型),不带调用方的字节。
846
+ */
847
+ export declare function buildUnattendedPolicyRefusal(reason: string): UnattendedPolicyRefusal;
841
848
  /** 「无人可答」终局的**闭集** —— core `AskOutcome` 联合里只有这两臂由 {@link ToolApprovalCoordinator.unattendedOutcome}
842
849
  * 铸:park 政策的路由答 `"unavailable"`,deny 政策的 {@link UnattendedPolicyRefusal}。没有布尔臂(S-689:裸 `false`
843
850
  * 是人的拒绝,这个口从不替人说话)。 */
@@ -210,6 +210,9 @@ export function parseToolApprovalResponse(body) {
210
210
  }
211
211
  return { ok: false, error: 'decision must be one of "allow" | "allow_session" | "deny"' };
212
212
  }
213
+ export function buildUnattendedPolicyRefusal(reason) {
214
+ return { allow: false, settledBy: POLICY_REFUSAL_DECISION.settledBy, reason };
215
+ }
213
216
  function unattendedRefusalReason(arm) {
214
217
  return ("UNATTENDED_APPROVAL_POLICY=deny: this deployment declares that no one attends its approvals, so an approval " +
215
218
  `request no one can answer is refused here instead of being parked for a person (arm: ${arm}).`);
@@ -399,7 +402,7 @@ export class ToolApprovalCoordinator {
399
402
  outcomeWord = "unavailable";
400
403
  break;
401
404
  case "deny":
402
- outcome = { allow: false, settledBy: POLICY_REFUSAL_DECISION.settledBy, reason: unattendedRefusalReason(site.arm) };
405
+ outcome = buildUnattendedPolicyRefusal(unattendedRefusalReason(site.arm));
403
406
  outcomeWord = "deny";
404
407
  break;
405
408
  default: {
@@ -17,7 +17,7 @@
17
17
  * ⑨⑩([ref],2026-08-31)把两个最大的 wire 面也纳入:TaskSpec 按「server 有无写点」记 Wired/Unwired,
18
18
  * TaskResult 按「转发/摘除」记 Forwarded/Stripped —— 谁改 resolve-spec.ts / runs.ts 的发射点谁同步本文件。
19
19
  */
20
- import type { TaskNotificationPayload, SemaProvenance, BackgroundChildEvent, RosterEntry, AskRequest, CheckpointSummary, PendingAction, TaskEvent, TraceEvent, MailboxMessage, MailboxAppendMessage, TaskSpec, TaskResult, SideQueryResult, WiringManifest, GateOutcome, GateDisposition, Settlement, PausedCause, WorkflowRun, ReadRootGrantCandidate, McpReconnectResult, McpServerStatus, NodeLspManagerOptions, RunnerDeps } from "@sema-agent/core";
20
+ import type { TaskNotificationPayload, SemaProvenance, BackgroundChildEvent, RosterEntry, AskRequest, CheckpointSummary, PendingAction, TaskEvent, TraceEvent, MailboxMessage, MailboxAppendMessage, TaskSpec, TaskResult, SideQueryResult, WiringManifest, GateOutcome, GateDisposition, Settlement, PausedCause, TerminalCause, WorkflowRun, ReadRootGrantCandidate, McpReconnectResult, McpServerStatus, NodeLspManagerOptions, RunnerDeps, SessionMemoryStatus } from "@sema-agent/core";
21
21
  import type { ToolApprovalFrame } from "../tool-approval.js";
22
22
  /** 编译期断言:T 必须收敛到 never(有残余键 = tsc 红)。
23
23
  * 🔴 **导出**([ref] U1/U2):本仓「词表闭集,未处置成员=编译红」的钉法只许有**一个**形 ——
@@ -157,6 +157,17 @@ type PausedCauseForwarded = "kind" | "gate" | "checkpointId" | "restoreMode";
157
157
  type PausedCauseStripped = "token";
158
158
  type _GuardPausedCause = AssertAllKeysHandled<Exclude<keyof PausedCause, PausedCauseForwarded | PausedCauseStripped>>;
159
159
  type _GuardPausedCauseReverse = AssertAllKeysHandled<Exclude<PausedCauseForwarded | PausedCauseStripped, keyof PausedCause>>;
160
+ type NonPausedTerminalKeys = Exclude<TerminalCause, {
161
+ kind: "paused";
162
+ }> extends infer C ? (C extends object ? keyof C : never) : never;
163
+ type TerminalArmForwarded = "kind" | "code" | "message" | "nestedPause" | "detail" | "reason";
164
+ type TerminalArmStripped = never;
165
+ type _GuardTerminalArm = AssertAllKeysHandled<Exclude<NonPausedTerminalKeys, TerminalArmForwarded | TerminalArmStripped>>;
166
+ type _GuardTerminalArmReverse = AssertAllKeysHandled<Exclude<TerminalArmForwarded | TerminalArmStripped, NonPausedTerminalKeys>>;
167
+ type SessionMemoryStatusProjected = "captureOptedOut" | "committedCount" | "foldedCount" | "optOutSource" | "lastCaptureAt";
168
+ type SessionMemoryStatusExcluded = "writeHome";
169
+ type _GuardSessionMemoryStatus = AssertAllKeysHandled<Exclude<keyof SessionMemoryStatus, SessionMemoryStatusProjected | SessionMemoryStatusExcluded>>;
170
+ type _GuardSessionMemoryStatusReverse = AssertAllKeysHandled<Exclude<SessionMemoryStatusProjected | SessionMemoryStatusExcluded, keyof SessionMemoryStatus>>;
160
171
  type SideQueryResultForwarded = "text" | "toolCalls" | "model" | "servedModel" | "degraded" | "responseModel" | "usage" | "usageMissing" | "stopReason" | "errorMessage";
161
172
  type SideQueryResultStripped = never;
162
173
  type _GuardSideQueryResult = AssertAllKeysHandled<Exclude<keyof SideQueryResult, SideQueryResultForwarded | SideQueryResultStripped>>;
@@ -226,6 +237,10 @@ type RunnerDepsLedgerWired = "fileHistoryCapture";
226
237
  type _GuardLedgerRunnerDepsReverse = AssertAllKeysHandled<Exclude<RunnerDepsLedgerWired, keyof RunnerDeps>>;
227
238
  export type CoreKeysetGuards = [
228
239
  _GuardNotification,
240
+ _GuardTerminalArm,
241
+ _GuardTerminalArmReverse,
242
+ _GuardSessionMemoryStatus,
243
+ _GuardSessionMemoryStatusReverse,
229
244
  _GuardNotificationReverse,
230
245
  _GuardProvenance,
231
246
  _GuardProvenanceReverse,
@@ -16,38 +16,8 @@
16
16
  * 同文件 G1 e2e 的全码扇出夹具、以及契约文档附录 D.3 那张表(含**小节标题里的码数** —— 那道门本批
17
17
  * 刚立,立完就在下一次加码时自己咬住了)。
18
18
  */
19
- export declare const ENGINE_NOTICE_WIRE_CODES: readonly ["memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "task.turn_interrupted", "steering.parked_input_blocked", "task.user_steer_undrained", "task.user_followup_undrained", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "task.halt_unconsumed", "task.late_approval", "config.durable_gate_unavailable", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "mcp.injection_dropped", "delegation.ask_unresolvable", "task.interrupt_unconsumed", "approval.read_root_granted", "approval.read_root_grant_rejected", "instructions.source_changed", "session.records_skipped", "server.model_unreachable", "classifier.unavailable", "config.permission_mode_unavailable", "permission.ask_released_by_mode", "permission.read_deny_unapplied", "permission.rule_store_unavailable"];
19
+ export declare const ENGINE_NOTICE_WIRE_CODES: readonly ["memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "task.turn_interrupted", "steering.parked_input_blocked", "task.user_steer_undrained", "task.user_followup_undrained", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "task.halt_unconsumed", "task.late_approval", "config.durable_gate_unavailable", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "mcp.injection_dropped", "delegation.ask_unresolvable", "task.interrupt_unconsumed", "approval.read_root_granted", "approval.read_root_grant_rejected", "instructions.source_changed", "session.records_skipped", "server.model_unreachable", "classifier.unavailable", "config.permission_mode_unavailable", "permission.ask_released_by_mode", "permission.read_deny_unapplied", "permission.rule_store_unavailable", "task.tool_turn_incomplete"];
20
20
  export type EngineNoticeWireCode = (typeof ENGINE_NOTICE_WIRE_CODES)[number];
21
- /**
22
- * S-690 —— **本仓自铸**的通告码与它们的受众(码 / 铸点 / 受众三者的属主都是本仓)。
23
- *
24
- * 为什么可以有这一族:core 的 `EngineNotice.code` 刻意是开放 `string`,顶注逐字「a host forwarding its own notices
25
- * through the same sink is a supported shape」—— 部署经同一只 `onNotice` 转发体投自己的通告,白名单 / 归属 / 消毒 /
26
- * 隔离四件判据一个不绕。为什么受众写在这里:core 的 `NOTICE_AUDIENCE` 只收录 core 自己铸的码,对本仓的码
27
- * `noticeAudienceOf` 只会读保守默认 `"operator"` —— 那是「不认识」,不是判决。顶注的「本仓不许自造第二份受众语义」
28
- * 说的是 **core 的码**;本表只收 core 码册里**没有**的码,这里是它们唯一的一份受众语义。
29
- *
30
- * 🔴 机器门(`test/engine-notice-wire.test.ts` 的 server 自铸码属主门):① 本表的码与 core 运行期码册 `ENGINE_NOTICE_CODES`
31
- * **零交集**(core 哪天铸了同名码 ⇒ 属主变成两个,当场红:销本表的行、改走 core 的受众与工厂);② 判 `"user"` 的码必须
32
- * 入白名单;③ 白名单里的码,受众一律由「本表 ∪ core 注册表」给,判出来必须是 `"user"`。
33
- */
34
- export declare const SERVER_NOTICE_AUDIENCE: {
35
- readonly "instructions.source_changed": "user";
36
- /**
37
- * O2(7.101.0 合并复审)—— boot 期探测到 ≤7.30.x 引擎留下的锁住的记忆层(唯一铸点 `src/memory-layer-legacy-lock.ts`,
38
- * 调用点 = `boot/stores.ts` 的 file 记忆引擎装配段)。受众 **operator**:恢复动词是运维在数据根上执行一次 `chmod -R u+w`,
39
- * 而且铸在 boot 期、没有任何会话 ⇒ 不上 wire、不进白名单,只走转发体的日志终点(`engine_notice` warn)。
40
- * `detail: { layers, remedy }` —— 层名(挂载面的直接子目录名)与那一句补救;条目名 / 内容不进。
41
- */
42
- readonly "memory.layer_locked_legacy": "operator";
43
- /** S-612 —— 会话存档部分记录被跳过(唯一铸点 `src/session-records-skipped-notice.ts`);受众 user,理由见白名单键注。 */
44
- readonly "session.records_skipped": "user";
45
- /** S-925 —— 模型调用因建连失败终态(唯一铸点 `src/model-unreachable-notice.ts`);受众 user,理由见白名单键注。 */
46
- readonly "server.model_unreachable": "user";
47
- };
48
- export type ServerNoticeCode = keyof typeof SERVER_NOTICE_AUDIENCE;
49
- /** 该码是否本仓自铸(属主判据单点:门与投递面都读这一个)。 */
50
- export declare function isServerNoticeCode(code: string): code is ServerNoticeCode;
51
21
  /** 白名单谓词(单点):路由与门都读这一个,不许第二处手抄码串。 */
52
22
  export declare function isEngineNoticeWireCode(code: string): code is EngineNoticeWireCode;
53
23
  /**
@@ -104,8 +74,19 @@ export declare function engineNoticeEventData(notice: {
104
74
  message: string;
105
75
  detail?: Record<string, unknown>;
106
76
  }, sessionId: string, nowMs: number): EngineNoticeWireRow;
107
- /** 一条 run 腿注册进来的投递口。抛错由 {@link EngineNoticeRouter.route} 隔离(见那里的 fail-open 登记)。 */
108
- export type EngineNoticeWireSink = (row: EngineNoticeWireRow) => void;
77
+ /**
78
+ * 一条 run 腿注册进来的投递口。抛错由 {@link EngineNoticeRouter.route} 隔离(见那里的 fail-open 登记)。
79
+ * 返回值只对 {@link EngineNoticeRouter.routeConfirmed} 有意义(S-928 codex r1-F2 / r2-F1):durable 口交回它那一次账本写的 thenable,
80
+ * 兑现 `true` = 这一帧落进了账本;`false` / 拒绝 = 没落(写失败已由口自己留痕);同步口(live SSE)不交 thenable,**不是**落账确认。`route()` 不读返回值。
81
+ */
82
+ export type EngineNoticeWireSink = (row: EngineNoticeWireRow) => unknown;
83
+ /** 口的槽位(闭集):`live` = 流上的人(SSE),`durable` = 这条 run 的事件账。{@link EngineNoticeRouter.routeConfirmed} 只数 `durable` 槽的落账确认。 */
84
+ export type EngineNoticeSlot = "live" | "durable";
85
+ /** 一条腿的两只具名口(各自可缺席:bg 腿没有 live SSE,ephemeral sync 腿没有账本)。 */
86
+ export interface EngineNoticeLegSlots {
87
+ readonly live?: EngineNoticeWireSink;
88
+ readonly durable?: EngineNoticeWireSink;
89
+ }
109
90
  /**
110
91
  * sessionId → 该会话活跃 run 腿的投递口。**进程内**(`defaultSubagentTailBus` 同款姿势):
111
92
  * `onNotice` 是同进程的同步回调,通告与它要投的那条腿必然同副本,跨副本路由这件事结构上不存在。
@@ -130,8 +111,8 @@ export declare class EngineNoticeRouter {
130
111
  *
131
112
  * 每只口各自 try 隔离在 {@link route} 里,单口抛不牵连同腿另一口。
132
113
  */
133
- registerLeg(sessionId: string, sinks: readonly EngineNoticeWireSink[]): () => void;
134
- /** 单口腿的便捷形(测试与单终点腿用)。 */
114
+ registerLeg(sessionId: string, slots: EngineNoticeLegSlots): () => void;
115
+ /** 单口腿的便捷形(测试用):登记为 **durable** 槽 —— 这样 `route()` 与 `routeConfirmed()` 两条路都能用一只口测到(live 槽对后者结构上恒不计)。 */
135
116
  register(sessionId: string, sink: EngineNoticeWireSink): () => void;
136
117
  /** 诊断/门用:某会话此刻**最新代**有几只口在听(旧代不计——它们收不到帧)。 */
137
118
  sinkCount(sessionId: string): number;
@@ -148,6 +129,20 @@ export declare class EngineNoticeRouter {
148
129
  detail?: Record<string, unknown>;
149
130
  sessionId?: unknown;
150
131
  }, nowMs?: number): number;
132
+ /**
133
+ * S-928(codex r1-F2 / r2-F1;[ref] opus 腿 L2 改按槽判):同一只扇出,但**等**每只口交回的持久化结果 —— 返回**确认落账**的口数:
134
+ * 只数 **`durable` 槽**里交回 thenable 且兑现 `true` 的口;兑现 `false` / 拒绝不算;`live` 槽**结构上不计**(不管它返回什么 —— 流上的人收没收到说明不了落账;
135
+ * 修前按「交回 thenable」反推是不是 durable 口,哪天 live 口改成 async 返回 `true` 就会被计成落账)。给「投出之后要记一笔『告知过』」的铸点用:
136
+ * 记账必须跟在**落账**之后,不能跟在「调用了口」之后(后台腿唯一的口是事件账 append,写失败时 `route()` 的计数照样是 1)。判据(白名单 / 归属 / 最新代 / 隔离)与 `route()` 逐字同一条。
137
+ */
138
+ routeConfirmed(notice: {
139
+ code: string;
140
+ message: string;
141
+ detail?: Record<string, unknown>;
142
+ sessionId?: unknown;
143
+ }, nowMs?: number): Promise<number>;
144
+ /** 两只入口共用的扇出:返回每只**没抛**的口的持久化结果(promise 形一律先接住拒绝 ⇒ 绝不留未处理拒绝)。 */
145
+ private fanOut;
151
146
  }
152
147
  /** 进程单例(`defaultSubagentTailBus` 同款):分流器与 run 腿直接 import,不穿长参数列表;测试自 new 独立实例。 */
153
148
  export declare const defaultEngineNoticeRouter: EngineNoticeRouter;
@@ -33,16 +33,8 @@ export const ENGINE_NOTICE_WIRE_CODES = [
33
33
  "permission.ask_released_by_mode",
34
34
  "permission.read_deny_unapplied",
35
35
  "permission.rule_store_unavailable",
36
+ "task.tool_turn_incomplete",
36
37
  ];
37
- export const SERVER_NOTICE_AUDIENCE = {
38
- "instructions.source_changed": "user",
39
- "memory.layer_locked_legacy": "operator",
40
- "session.records_skipped": "user",
41
- "server.model_unreachable": "user",
42
- };
43
- export function isServerNoticeCode(code) {
44
- return Object.hasOwn(SERVER_NOTICE_AUDIENCE, code);
45
- }
46
38
  const WIRE_CODES = new Set(ENGINE_NOTICE_WIRE_CODES);
47
39
  export function isEngineNoticeWireCode(code) {
48
40
  return WIRE_CODES.has(code);
@@ -107,11 +99,13 @@ export function engineNoticeEventData(notice, sessionId, nowMs) {
107
99
  ts: nowMs,
108
100
  };
109
101
  }
102
+ const isThenableResult = (v) => typeof v === "object" && v !== null && typeof v.then === "function";
110
103
  let nextLegId = 1;
111
104
  export class EngineNoticeRouter {
112
105
  legs = new Map();
113
- registerLeg(sessionId, sinks) {
114
- const leg = { legId: nextLegId++, sinks: [...sinks] };
106
+ registerLeg(sessionId, slots) {
107
+ const sinks = [...(slots.live ? [{ slot: "live", sink: slots.live }] : []), ...(slots.durable ? [{ slot: "durable", sink: slots.durable }] : [])];
108
+ const leg = { legId: nextLegId++, sinks };
115
109
  const list = this.legs.get(sessionId) ?? [];
116
110
  list.push(leg);
117
111
  this.legs.set(sessionId, list);
@@ -131,7 +125,7 @@ export class EngineNoticeRouter {
131
125
  };
132
126
  }
133
127
  register(sessionId, sink) {
134
- return this.registerLeg(sessionId, [sink]);
128
+ return this.registerLeg(sessionId, { durable: sink });
135
129
  }
136
130
  sinkCount(sessionId) {
137
131
  const list = this.legs.get(sessionId);
@@ -141,27 +135,34 @@ export class EngineNoticeRouter {
141
135
  return this.legs.get(sessionId)?.length ?? 0;
142
136
  }
143
137
  route(notice, nowMs = Date.now()) {
138
+ return this.fanOut(notice, nowMs).length;
139
+ }
140
+ async routeConfirmed(notice, nowMs = Date.now()) {
141
+ const outcomes = await Promise.all(this.fanOut(notice, nowMs));
142
+ return outcomes.filter((ok) => ok).length;
143
+ }
144
+ fanOut(notice, nowMs) {
144
145
  if (!isEngineNoticeWireCode(notice.code))
145
- return 0;
146
+ return [];
146
147
  const sessionId = engineNoticeSessionId(notice);
147
148
  if (sessionId === undefined)
148
- return 0;
149
+ return [];
149
150
  const list = this.legs.get(sessionId);
150
151
  const leg = list && list.length > 0 ? list[list.length - 1] : undefined;
151
152
  if (!leg || leg.sinks.length === 0)
152
- return 0;
153
+ return [];
153
154
  const row = engineNoticeEventData(notice, sessionId, nowMs);
154
- let delivered = 0;
155
- for (const sink of leg.sinks) {
155
+ const outcomes = [];
156
+ for (const { slot, sink } of leg.sinks) {
156
157
  try {
157
- sink(row);
158
- delivered++;
158
+ const result = sink(row);
159
+ outcomes.push(isThenableResult(result) ? Promise.resolve(result).then((v) => slot === "durable" && v === true, () => false) : Promise.resolve(false));
159
160
  }
160
161
  catch {
161
162
  recordFailOpen("server.engine-notice.wire-sink-threw", `code=${notice.code}`);
162
163
  }
163
164
  }
164
- return delivered;
165
+ return outcomes;
165
166
  }
166
167
  }
167
168
  export const defaultEngineNoticeRouter = new EngineNoticeRouter();
@@ -169,11 +170,10 @@ export function registerEngineNoticeLeg(opts) {
169
170
  const { sessionId, live, durable } = opts;
170
171
  if (sessionId === undefined || sessionId.length === 0)
171
172
  return () => undefined;
172
- const sinks = [...(live ? [live] : []), ...(durable ? [durable] : [])];
173
- if (sinks.length === 0)
173
+ if (live === undefined && durable === undefined)
174
174
  return () => undefined;
175
175
  const router = opts.router ?? defaultEngineNoticeRouter;
176
- const unregister = router.registerLeg(sessionId, sinks);
176
+ const unregister = router.registerLeg(sessionId, { ...(live ? { live } : {}), ...(durable ? { durable } : {}) });
177
177
  for (const notice of opts.pending ?? [])
178
178
  router.route(notice);
179
179
  return unregister;