@sema-agent/server 7.107.0 → 7.109.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 (101) hide show
  1. package/MIGRATION.md +72 -1
  2. package/USAGE.md +51 -25
  3. package/dist/approval-ask-audit-store.d.ts +62 -4
  4. package/dist/approval-ask-audit-store.js +67 -11
  5. package/dist/approval-ask-machine.d.ts +9 -25
  6. package/dist/approval-ask-machine.js +5 -2
  7. package/dist/approval-card.d.ts +32 -3
  8. package/dist/approval-card.js +29 -2
  9. package/dist/approval.js +16 -13
  10. package/dist/bench/s1/live-deps.d.ts +4 -5
  11. package/dist/bench/s1/live-deps.js +9 -10
  12. package/dist/boot/coordinators.d.ts +4 -0
  13. package/dist/boot/coordinators.js +3 -2
  14. package/dist/boot/leader.js +0 -2
  15. package/dist/boot/resolve-spec.d.ts +1 -1
  16. package/dist/boot/resolve-spec.js +3 -8
  17. package/dist/boot/shutdown.d.ts +8 -0
  18. package/dist/boot/shutdown.js +31 -8
  19. package/dist/boot/stage-06-runners.d.ts +1 -0
  20. package/dist/boot/stage-06-runners.js +6 -3
  21. package/dist/boot/stage-07-capability-layer.d.ts +1 -0
  22. package/dist/boot/stage-08-reapers.d.ts +1 -0
  23. package/dist/boot/stage-09-leader.d.ts +1 -0
  24. package/dist/boot/stage-10-http-server.d.ts +1 -0
  25. package/dist/boot/stage-10-http-server.js +3 -1
  26. package/dist/brain.js +2 -1
  27. package/dist/capabilities/scenarios.d.ts +3 -2
  28. package/dist/config-catalog.js +1 -3
  29. package/dist/config-center/apply-effective.js +2 -0
  30. package/dist/config-center/types.d.ts +7 -0
  31. package/dist/config-provider.js +1 -0
  32. package/dist/config-types.d.ts +2 -9
  33. package/dist/config.js +2 -5
  34. package/dist/deployment-governance.js +2 -1
  35. package/dist/fleet-client.d.ts +10 -2
  36. package/dist/fleet-client.js +14 -8
  37. package/dist/http/admission.js +8 -2
  38. package/dist/http/memory-compliance-failure-reply.d.ts +41 -0
  39. package/dist/http/memory-compliance-failure-reply.js +73 -0
  40. package/dist/http/memory-store-refusal-reply.d.ts +2 -2
  41. package/dist/http/resume-legs.js +3 -2
  42. package/dist/http/route-ctx.d.ts +3 -3
  43. package/dist/http/routes/capabilities.js +1 -0
  44. package/dist/http/routes/diagnostics.js +2 -0
  45. package/dist/http/routes/memory-compliance.js +2 -34
  46. package/dist/http/routes/memory-origin.d.ts +1 -1
  47. package/dist/http/routes/sessions.js +3 -11
  48. package/dist/http/routes/side-query.js +7 -2
  49. package/dist/http/routes/tasks.js +2 -1
  50. package/dist/http/server.d.ts +2 -2
  51. package/dist/http/wire-types.d.ts +8 -0
  52. package/dist/leader/diffup.d.ts +53 -0
  53. package/dist/leader/diffup.js +95 -1
  54. package/dist/leader/leader.d.ts +0 -6
  55. package/dist/leader/leader.js +1 -1
  56. package/dist/leader/repair-wire.js +1 -1
  57. package/dist/leader/wire.d.ts +3 -10
  58. package/dist/leader/wire.js +39 -13
  59. package/dist/memory-store-refusal.d.ts +2 -1
  60. package/dist/model-entry-headers.d.ts +41 -0
  61. package/dist/model-entry-headers.js +61 -0
  62. package/dist/model-entry-refusal.d.ts +6 -0
  63. package/dist/model-entry-refusal.js +4 -0
  64. package/dist/model-reach-scope.d.ts +55 -0
  65. package/dist/model-reach-scope.js +16 -0
  66. package/dist/model-unreachable-notice.d.ts +38 -0
  67. package/dist/model-unreachable-notice.js +72 -0
  68. package/dist/observability/fail-open.d.ts +5 -5
  69. package/dist/observability/fail-open.js +5 -5
  70. package/dist/observability/run-terminal.d.ts +2 -1
  71. package/dist/outbound-dispatcher.d.ts +69 -0
  72. package/dist/outbound-dispatcher.js +123 -3
  73. package/dist/plugins/approval-ask-store-file.d.ts +2 -1
  74. package/dist/plugins/approval-ask-store-file.js +3 -0
  75. package/dist/plugins/approval-ask-store-memory.d.ts +2 -1
  76. package/dist/plugins/approval-ask-store-memory.js +7 -1
  77. package/dist/plugins/approval-ask-store-sql.d.ts +18 -0
  78. package/dist/plugins/approval-ask-store-sql.js +7 -0
  79. package/dist/plugins/park-commit-watch.d.ts +48 -0
  80. package/dist/plugins/park-commit-watch.js +87 -0
  81. package/dist/request-key-closure.js +1 -1
  82. package/dist/router/route-orchestration.js +7 -2
  83. package/dist/run-local.js +2 -3
  84. package/dist/runs.js +3 -2
  85. package/dist/runtime-governance.d.ts +3 -3
  86. package/dist/runtime-governance.js +3 -2
  87. package/dist/task-settings.js +4 -2
  88. package/dist/tool-approval.d.ts +99 -15
  89. package/dist/tool-approval.js +75 -10
  90. package/dist/tool-rule-names.d.ts +25 -0
  91. package/dist/tool-rule-names.js +5 -0
  92. package/dist/trace/core-keyset-guard.d.ts +14 -2
  93. package/dist/trace/engine-notice-wire.d.ts +3 -1
  94. package/dist/trace/engine-notice-wire.js +2 -0
  95. package/dist/trace/project.d.ts +3 -1
  96. package/dist/trace/project.js +2 -3
  97. package/dist/trace/self-minted-wire.d.ts +71 -0
  98. package/dist/trace/self-minted-wire.js +28 -0
  99. package/dist/unattended-permission-mode.d.ts +54 -93
  100. package/dist/unattended-permission-mode.js +8 -61
  101. package/package.json +3 -3
@@ -410,6 +410,10 @@ export interface BatchRow {
410
410
  updatedAtMs: number;
411
411
  }
412
412
  /** design 定稿 §4 的持久层接口。协调器接线(车2)、恢复扫描消费(车5)不在本车范围——本车只落这些方法。 */
413
+ /** 一次调用在一条腿上的坐标({@link ApprovalAskStore.listByCall} 的四列;型从 {@link AskRow} 派生,不手抄)。 */
414
+ export type AskCallCoordinate = Pick<AskRow, "taskId" | "legKey" | "toolCallId" | "sourceTaskId">;
415
+ /** 「这一行在这次调用上」的唯一判据(四列逐字相等):InMemory twin 的筛子与 SQL twin 的出口对齐读同一只。 */
416
+ export declare function askRowOnCall(row: AskRow, call: AskCallCoordinate): boolean;
413
417
  export interface ApprovalAskStore {
414
418
  /** 幂等 upsert。返回**行 + 本次是否真插入**(判别位语义见 {@link EnsureAskResult})。 */
415
419
  ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
@@ -464,6 +468,19 @@ export interface ApprovalAskStore {
464
468
  * 等待」只被**部分**缓解(不再叠加调用方侧的等待,池侧仍会堆)。真正的取消要 driver 级 seam,登记为
465
469
  * 后续件。 */
466
470
  listPendingBySession(sessionId: string, owner: string | null, signal?: AbortSignal): Promise<AskRow[]>;
471
+ /**
472
+ * 🔴 S-944 / SN-C1:**这次调用在这条腿上的全部行** —— 任意状态、任意 ask 身份(同一调用不同类集 = 不同 askId 的几只问、升级前按旧派生
473
+ * 写的行,都在其中)。坐标 = {@link AskCallCoordinate} 四列**精确**匹配;序 = `created_at_ms ASC, ask_id ASC`(确定性)。
474
+ *
475
+ * 唯一消费者 = 会话授权短路的争议判据(`tool-approval.ts` `sessionGrantUncontestedByRow`):「这次调用在腿内有行 ⇒ 一揽子放行不得代答」。
476
+ * 为什么按调用而不是按 askId 点读:争议的真源是**调用**(人对这次调用说过什么 / 这次调用还有一张卡挂着),ask 身份只是其中一问的主键;
477
+ * 身份可以加轴(S-944 加了类集),争议判据不得依赖身份 —— 按身份点读在身份加轴的那一刻就把同调用的别的问、旧派生的行读成「不存在」。
478
+ *
479
+ * 三 twin 同答:SQL twin 读后按四列逐字再滤一遍 —— MySQL 协议的 VARCHAR 等值带 PAD SPACE(`'tc'` = `'tc '`),PG(`COLLATE "C"`)与
480
+ * InMemory 严格比较;不在入口拒(调用 id 是引擎铸的原值,首尾空白不归本店判),在出口对齐。索引:走 `idx_ask_task_state`
481
+ * `(task_id, state)` 的 `task_id` 前缀(一条 run 的行数 = 真出过卡的问数;grant 代答的问不落行),不另加索引。
482
+ */
483
+ listByCall(call: AskCallCoordinate): Promise<AskRow[]>;
467
484
  getAsk(askId: string): Promise<AskRow | null>;
468
485
  /** 回决幂等回放的读口。🔴 `taskId` **必填**且是键的第一维——UNIQUE 已改 `(task_id, idempotency_key)`
469
486
  * (车4 §12-E),裸按 key 全局查在多租户库上既可能回**别人 task** 的行(存在性外泄),也可能在同 key
@@ -621,6 +638,7 @@ export declare class SqlApprovalAskStore implements ApprovalAskStore {
621
638
  listByState(state: AskState, limit: number): Promise<AskRow[]>;
622
639
  listPendingByTask(taskId: string, signal?: AbortSignal): Promise<AskRow[]>;
623
640
  listPendingBySession(sessionId: string, owner: string | null, signal?: AbortSignal): Promise<AskRow[]>;
641
+ listByCall(call: AskCallCoordinate): Promise<AskRow[]>;
624
642
  getAsk(askId: string): Promise<AskRow | null>;
625
643
  getByIdempotencyKey(taskId: string, key: string): Promise<AskRow | null>;
626
644
  deferReconcile(askId: string, expectedState: AskState, expectedRev: number, nowMs: number): Promise<boolean>;
@@ -81,6 +81,9 @@ export function projectAskTerminalClaimTruth(row) {
81
81
  settledBy: row.settledBy,
82
82
  };
83
83
  }
84
+ export function askRowOnCall(row, call) {
85
+ return row.taskId === call.taskId && row.legKey === call.legKey && row.toolCallId === call.toolCallId && row.sourceTaskId === call.sourceTaskId;
86
+ }
84
87
  export const APPROVAL_ASK_TABLE = "approval_ask";
85
88
  export const APPROVAL_BATCH_TABLE = "approval_batch";
86
89
  export const TIDB_APPROVAL_ASK_STATEMENTS = [
@@ -675,6 +678,10 @@ export class SqlApprovalAskStore {
675
678
  : this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = ? AND state = 'STREAM_PENDING' AND owner = ? ORDER BY created_at_ms ASC`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = $1 AND state = 'STREAM_PENDING' AND owner = $2 ORDER BY created_at_ms ASC`), owner === null ? [sessionId] : [sessionId, owner]), signal, "listPendingBySession");
676
679
  return rows.map(mapAskRow);
677
680
  }
681
+ async listByCall(call) {
682
+ const { rows } = await this.db.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = ? AND leg_key = ? AND tool_call_id = ? AND source_task_id = ? ORDER BY created_at_ms ASC, ask_id ASC`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = $1 AND leg_key = $2 AND tool_call_id = $3 AND source_task_id = $4 ORDER BY created_at_ms ASC, ask_id ASC`), [call.taskId, call.legKey, call.toolCallId, call.sourceTaskId]);
683
+ return rows.map(mapAskRow).filter((r) => askRowOnCall(r, call));
684
+ }
678
685
  async getAsk(askId) {
679
686
  const { rows } = await this.db.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ?`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = $1`), [
680
687
  askId,
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 🔴 **S-929 F1(7.108.0 [ref] 合并复审 codex 腿 A ①,[high] 回归修)—— park 提交的唯一观察点。**
3
+ *
4
+ * core 的 durable park 就是对本仓 checkpoint 店的一次 `put`(SQL 孪生 / 本地文件店都是本仓的 `CheckpointStore` 实现,
5
+ * 一只实例、四个消费点 —— 见 `boot/stage-06-runners.ts` 的构造注)。审批协调器交出 park 路由(ask 回调返回 `"unavailable"`)
6
+ * **之后** core 才铸行、才 `put`;两者之间隔着 core 自己的 park 前置(会话叶、环境快照…)。所以「ask 回调返回」**不是**
7
+ * 「park 落盘」,有序关停若把前者当后者,就会在 `put` 之下关掉连接池(修前病:park 行没提交,审计账本却已记「结清」)。
8
+ *
9
+ * 本模块给店加**一层**装饰(两店同用,不读 core 私有键 —— [ref]):
10
+ * ① **计在飞 `put`**:`quiesce(deadlineMs)` 等到零或到期(绝对时刻,`Date.now()` 域),如实返回残余数 —— 收尾链在
11
+ * `backend.close()` 之前等它(`boot/shutdown.ts`),审批协调器的关停收尾也等它(`ToolApprovalCoordinator.drainForShutdown`);
12
+ * ② **报 park 提交** —— 「这只店里有了这一行」的两种观察,同一件事实:一次 `put` 落定(resolve);或一次 `put` 抛错之后,同一 token 的
13
+ * `get` 读到了行(写已落、回执丢了 —— core 的挂起序正是用这一次回读确认提交的,本层观察的就是那一次读,不另读;车内 codex r1)。
14
+ * 那一行是审批停车行(`pendingAction.kind === "tool_approval"`,kind 先判 —— core d.ts「Every consumer that reads the tool fields MUST
15
+ * branch on `kind` first」)⇒ 把**停车行自己陈述的**调用身份交给订阅者({@link CommittedParkCalls})。抛错且回读不到 = 没提交,不报。
16
+ * 身份取自**读到的那一行**(不是本进程想写的那一只):哪怕 token 撞了别人的行,报出去的也只是那一行真握着的调用。
17
+ *
18
+ * 订阅者契约:**不得抛**。订阅者跑在 `put` 已提交之后、返回给 core 之前 —— 一只抛错的订阅者会把一次**已提交**的 park 报成失败。
19
+ * 唯一的订阅者是审批协调器(写审计 settle 行,那只 sink 自吞失败并按 [ref] 留痕),它在自己那一侧兜住(同 tag 扩射程,见协调器)。
20
+ */
21
+ import type { CheckpointStore } from "@sema-agent/core";
22
+ /**
23
+ * 一只**已提交**的审批停车行说它握着哪些调用(停车行自己的陈述,原样,不是推断):
24
+ * · `sessionId` —— 行停住的会话 `cp.sessionId`(执行这次调用的会话:委派 / 后台子代的行落在**子会话**上)。core 铸行用的是 prepare-task 的
25
+ * 同一个 `sessionId`,它也是同一次调用的 ask 上的 `sourceTaskId`(`askSourceIdentity`)⇒ 订阅者按**相等**配,不做别名(车 SO:
26
+ * 调用 id 只在 run 内唯一,宿主会话若当子代的等价别名,宿主停车行会冒充子代的提交);
27
+ * · `toolCallIds` —— 停在门前的那只调用(`pendingAction.toolCallId`)∪ 同一条助手消息里**尚未执行**的调用
28
+ * (`batchToolCallIds − completedCallIds`,core d.ts「#k+1..N are still pending」):那些调用随这一行一起被持久挂起,
29
+ * 续跑时重新过门 —— 它们的等待同样由这一行承载。
30
+ */
31
+ export interface CommittedParkCalls {
32
+ readonly sessionId: string;
33
+ readonly toolCallIds: readonly string[];
34
+ }
35
+ /** 装饰层的观察面(`create*` 产出的活对象的一半;另一半是装饰后的店本身)。 */
36
+ export interface ParkCommitWatch {
37
+ /** 订阅 park 提交(见模块头「订阅者契约」:不得抛)。 */
38
+ onParkCommitted(listener: (calls: CommittedParkCalls) => void): void;
39
+ /** 等在飞 `put` 归零或到 `deadlineMs`(绝对时刻,`Date.now()` 域);返回那一刻的残余在飞数(0 = 静默了)。从不抛。 */
40
+ quiesce(deadlineMs: number): Promise<{
41
+ inFlight: number;
42
+ }>;
43
+ }
44
+ export declare function createParkCommitWatch<S extends Pick<CheckpointStore, "put" | "get">>(inner: S): {
45
+ store: S;
46
+ watch: ParkCommitWatch;
47
+ };
48
+ //# sourceMappingURL=park-commit-watch.d.ts.map
@@ -0,0 +1,87 @@
1
+ function committedParkCallsOf(cp) {
2
+ const pa = cp.pendingAction;
3
+ if (pa.kind !== "tool_approval")
4
+ return undefined;
5
+ const completed = new Set(pa.completedCallIds);
6
+ const toolCallIds = [pa.toolCallId, ...pa.batchToolCallIds.filter((id) => id !== pa.toolCallId && !completed.has(id))];
7
+ return { sessionId: cp.sessionId, toolCallIds };
8
+ }
9
+ const UNCONFIRMED_PUT_TOKENS_MAX = 256;
10
+ export function createParkCommitWatch(inner) {
11
+ const listeners = [];
12
+ let inFlight = 0;
13
+ const idleWaiters = new Set();
14
+ const unconfirmed = new Set();
15
+ const notify = (row) => {
16
+ const calls = committedParkCallsOf(row);
17
+ if (calls !== undefined)
18
+ for (const l of listeners)
19
+ l(calls);
20
+ };
21
+ const put = async (token, cp) => {
22
+ inFlight += 1;
23
+ try {
24
+ try {
25
+ await inner.put(token, cp);
26
+ }
27
+ catch (err) {
28
+ unconfirmed.add(token);
29
+ if (unconfirmed.size > UNCONFIRMED_PUT_TOKENS_MAX) {
30
+ const oldest = unconfirmed.values().next();
31
+ if (oldest.done !== true)
32
+ unconfirmed.delete(oldest.value);
33
+ }
34
+ throw err;
35
+ }
36
+ notify(cp);
37
+ }
38
+ finally {
39
+ inFlight -= 1;
40
+ if (inFlight === 0)
41
+ for (const wake of [...idleWaiters])
42
+ wake();
43
+ }
44
+ };
45
+ const get = async (token) => {
46
+ if (!unconfirmed.has(token))
47
+ return inner.get(token);
48
+ unconfirmed.delete(token);
49
+ const row = await inner.get(token);
50
+ if (row !== null)
51
+ notify(row);
52
+ return row;
53
+ };
54
+ const store = new Proxy(inner, {
55
+ get(target, prop) {
56
+ if (prop === "put")
57
+ return put;
58
+ if (prop === "get")
59
+ return get;
60
+ const v = Reflect.get(target, prop, target);
61
+ return typeof v === "function" ? v.bind(target) : v;
62
+ },
63
+ });
64
+ const watch = {
65
+ onParkCommitted(listener) {
66
+ listeners.push(listener);
67
+ },
68
+ quiesce(deadlineMs) {
69
+ if (inFlight === 0 || Date.now() >= deadlineMs)
70
+ return Promise.resolve({ inFlight });
71
+ return new Promise((resolve) => {
72
+ const timer = setTimeout(() => {
73
+ idleWaiters.delete(wake);
74
+ resolve({ inFlight });
75
+ }, deadlineMs - Date.now());
76
+ const wake = () => {
77
+ clearTimeout(timer);
78
+ idleWaiters.delete(wake);
79
+ resolve({ inFlight: 0 });
80
+ };
81
+ idleWaiters.add(wake);
82
+ });
83
+ },
84
+ };
85
+ return { store, watch };
86
+ }
87
+ //# sourceMappingURL=park-commit-watch.js.map
@@ -20,7 +20,7 @@ export function keyClosureClause(names, label) {
20
20
  const TASK_BODY_DECLARED_KEYS = [
21
21
  "a2aPeers", "acceptPartialRestore", "additionalDirectories", "additionalReadDirectories", "agents",
22
22
  "appendSystemPrompt", "attachmentIds", "attachments", "capabilitiesNeeded", "cascade", "clientContext",
23
- "compaction", "compactionModel", "council", "cwd", "debate", "deferTools", "deviceId", "enableFork",
23
+ "compaction", "compactionModel", "council", "cwd", "debate", "deferTools", "deviceId", "disableSkills", "enableFork",
24
24
  "excludeAllTools", "excludeTools", "fileHistory",
25
25
  "finalVerification", "forwardSubagentEvents", "images", "inheritEnv", "interactiveTools", "jobId", "limits", "maxCostUsd",
26
26
  "maxTokens", "mcpServers", "memory", "memoryCapture", "memoryWrite", "model", "objective", "oneShot", "outputRetries",
@@ -1,5 +1,6 @@
1
1
  import { COARSE_SHELL_TOOLS } from "@sema-agent/core";
2
2
  import { operatorAlwaysHumanAsk } from "../operator-ask.js";
3
+ import { heldRuleName } from "../tool-rule-names.js";
3
4
  export function routeOrchestration(s) {
4
5
  if (s.explicitMode)
5
6
  return { mode: s.explicitMode, reason: `explicit:${s.explicitMode}` };
@@ -42,9 +43,13 @@ export function isIsolatedExecEnv(provider, runtimeClass) {
42
43
  const WRITE_HAND_TOOLS = new Set(["Edit", "Write", "NotebookEdit"]);
43
44
  const SHELL_TOOLS = new Set(COARSE_SHELL_TOOLS);
44
45
  export function supPostureOverrides() {
45
- const ask = (toolName) => operatorAlwaysHumanAsk(`supervisor routing(capability-danger:${toolName})`, `${toolName} gated by supervisor routing (capability-danger task)`);
46
46
  const gate = {
47
- check: (req) => (WRITE_HAND_TOOLS.has(req.toolName) || SHELL_TOOLS.has(req.toolName) ? ask(req.toolName) : { action: "allow" }),
47
+ check: (req) => {
48
+ const hit = heldRuleName(req, (n) => WRITE_HAND_TOOLS.has(n) || SHELL_TOOLS.has(n));
49
+ return hit !== undefined
50
+ ? operatorAlwaysHumanAsk(`supervisor routing(capability-danger:${hit})`, `${req.toolName} gated by supervisor routing (capability-danger task)`)
51
+ : { action: "allow" };
52
+ },
48
53
  };
49
54
  return { shellGate: "always", toolPolicy: gate };
50
55
  }
package/dist/run-local.js CHANGED
@@ -9,7 +9,7 @@ import { createInterface } from "node:readline/promises";
9
9
  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
- import { createUnattendedLeg, unattendedPermissionMode, withUnattendedLeg } from "./unattended-permission-mode.js";
12
+ import { withUnattendedLeg } from "./unattended-permission-mode.js";
13
13
  import { createBrain } from "./brain.js";
14
14
  import { assertGuardPatternsUsable, buildOnlySensitiveBaselineWarning, createApprovalBaselinePolicy, createDeploymentGovernanceInputs, } from "./deployment-governance.js";
15
15
  import { applyRuntimeGovernance } from "./runtime-governance.js";
@@ -568,7 +568,6 @@ export async function runLocal(argv, deps = {}) {
568
568
  ...(maxCostUsd !== undefined ? { maxCostUsd } : {}),
569
569
  ...(maxTokens !== undefined ? { maxTokens } : {}),
570
570
  };
571
- const unattendedLeg = createUnattendedLeg(unattendedPermissionMode(config), runnerDeps);
572
571
  const spec = withUnattendedLeg(applyLocalGovernance({
573
572
  objective: mention.cleanedObjective,
574
573
  sessionId,
@@ -585,7 +584,7 @@ export async function runLocal(argv, deps = {}) {
585
584
  ...(mcp ? { mcp } : {}),
586
585
  ...(a2a ? { a2a } : {}),
587
586
  ...(Object.keys(limits).length > 0 ? { limits } : {}),
588
- }, config, workspaceDir, memoryEngine?.root), unattendedLeg);
587
+ }, config, workspaceDir, memoryEngine?.root), onAsk);
589
588
  logger.info("run_local_start", { scenario: scenarioName, model: spec.model, sessionId, exec: executionLaneOf(config.remoteExec?.provider) });
590
589
  const settleSessionBackground = async () => {
591
590
  const reaped = defaultTaskRegistry.reapSessionBackground(sessionId, scope);
package/dist/runs.js CHANGED
@@ -5,6 +5,7 @@ import { TOOL_DECLARATION_HTTP_STATUS } from "./tool-declaration-http-status.js"
5
5
  import { isSessionErrorCode, sessionErrorHttp } from "./session-error-wire.js";
6
6
  import { redactErrorMessage } from "./observability/run-terminal.js";
7
7
  import { withPrincipal } from "./observability/principal-context.js";
8
+ import { withModelReachScope } from "./model-reach-scope.js";
8
9
  import { redactSecrets } from "./trace/redact.js";
9
10
  import { taskNotificationEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "./trace/project.js";
10
11
  import { createLedgerSink } from "./trace/ledger-sink.js";
@@ -419,7 +420,7 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
419
420
  await append("suggestions", { suggestions: suggestions.map((s) => redactSecrets(s)) });
420
421
  }
421
422
  };
422
- await withPrincipal(principal, () => {
423
+ await withModelReachScope(spec.sessionId, () => withPrincipal(principal, () => {
423
424
  const withElicit = elicitation
424
425
  ? () => elicitation.runWithContext({ taskId, owner: elicitOwner ?? owner, emit: elicitEmit, abortSignal: cancelCtrl.signal }, runBody)
425
426
  : runBody;
@@ -442,7 +443,7 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
442
443
  return sendUserFile
443
444
  ? sendUserFile.runWithContext({ taskId, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); } }, withApproval)
444
445
  : withApproval();
445
- });
446
+ }));
446
447
  if (!reached && cancelCtrl.signal.aborted) {
447
448
  try {
448
449
  await flush();
@@ -188,9 +188,9 @@ export interface GovernancePosture {
188
188
  * (checkpoint 行没有这一列,core 的 `CheckpointGate`/`PendingAction`/`Checkpoint` 顶层都不带它)。反向
189
189
  * (checkpoint → ask)今天**没有读口**:两条 list 读口都硬过滤 `state='STREAM_PENDING'`,而 park 完的行是
190
190
  * `PARKED`;唯一精确的连接键是 ask 行上的 `gate_token`,要按它反查得给店加一个新读口(SQL 面 = 双库集成
191
- * 门)。派生 `deriveAskId(...)` 也不行:它吃 `legKey`(= 上一腿 resume token 的摘要)与 `parentToolCallId`,
192
- * 这两维从 checkpoint 行推不出来,二腿/子代形上会算出一个**错**的 id —— 在「等的谁」这条通道上,错 id
193
- * 比缺席坏得多(`http/active-run-conflict.ts` 的 `resumeEntryForGate` 的 default 臂是同一条纪律)。故诚实缺席。
191
+ * 门)。派生 `deriveAskId(...)` 也不行:它吃 `legKey`(= 上一腿 resume token 的摘要)、`parentToolCallId` 与这一问的
192
+ * 类集(S-944);前两维从 checkpoint 行推不出来,类集在 park 行上老行缺席、续跑不补 —— 二腿/子代/老行形上会算出一个
193
+ * **错**的 id —— 在「等的谁」这条通道上,错 id 比缺席坏得多(`http/active-run-conflict.ts` 的 `resumeEntryForGate` 的 default 臂是同一条纪律)。故诚实缺席。
194
194
  */
195
195
  export declare function governanceOriginOf(gate: {
196
196
  riskDescriptor?: {
@@ -2,6 +2,7 @@ import { combinePolicies, COARSE_SHELL_TOOLS as CORE_COARSE_SHELL_TOOLS, createC
2
2
  import { GovernanceConfig } from "@sema-agent/settings-schema";
3
3
  import { currentGovernanceAskMarks } from "./governance-ask-marks.js";
4
4
  import { operatorAsk, withDeploymentAuthorship } from "./operator-ask.js";
5
+ import { heldRuleName } from "./tool-rule-names.js";
5
6
  const COMMAND_RULE_SHAPE = GovernanceConfig.shape.commandPolicy.unwrap().element.shape;
6
7
  const COMMAND_DECISIONS = COMMAND_RULE_SHAPE.decision.options;
7
8
  export const AUTONOMY_MODES = GovernanceConfig.shape.autonomy.unwrap().options;
@@ -20,7 +21,7 @@ function createCommandAskListPolicy(askCommands, tools = COARSE_SHELL_TOOLS) {
20
21
  const ask = (message, rule) => operatorAsk(rule, message);
21
22
  return {
22
23
  check(req) {
23
- if (!shellTools.has(req.toolName))
24
+ if (heldRuleName(req, (n) => shellTools.has(n)) === undefined)
24
25
  return { action: "allow" };
25
26
  const command = req.args?.command;
26
27
  if (typeof command !== "string")
@@ -135,7 +136,7 @@ function createGovernanceShellGateMarkPolicy(marksOf, tools = COARSE_SHELL_TOOLS
135
136
  const shellTools = new Set(tools);
136
137
  return {
137
138
  check(req) {
138
- if (shellTools.has(req.toolName))
139
+ if (heldRuleName(req, (n) => shellTools.has(n)) !== undefined)
139
140
  marksOf()?.setMarked(req.toolCallId, true);
140
141
  return { action: "allow" };
141
142
  },
@@ -3,6 +3,7 @@ import { findUnmatchableToolNames, formatUnmatchableToolNames } from "./config.j
3
3
  import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
4
4
  import { MAX_KEY_ECHO, keyClosureClause, scanKeyClosure } from "./request-key-closure.js";
5
5
  import { parseHooksConfig } from "./hooks/hook-runner.js";
6
+ import { heldRuleName } from "./tool-rule-names.js";
6
7
  export const PERMISSION_MODE_REQUEST_FACE = {
7
8
  default: "request",
8
9
  acceptEdits: "request",
@@ -278,10 +279,11 @@ function createRuleListPolicy(names, behavior, pathBases) {
278
279
  }
279
280
  }
280
281
  const WORKFLOW_ASK_NAMES = [RUN_WORKFLOW_TOOL_NAME];
282
+ const isWorkflowName = (name) => name === RUN_WORKFLOW_TOOL_NAME;
281
283
  function createWorkflowAskPolicy(isExempt) {
282
284
  return {
283
285
  async check(req) {
284
- if (req.toolName !== RUN_WORKFLOW_TOOL_NAME)
286
+ if (heldRuleName(req, isWorkflowName) === undefined)
285
287
  return { action: "allow" };
286
288
  if (isExempt) {
287
289
  try {
@@ -299,7 +301,7 @@ function createWorkflowAskPolicy(isExempt) {
299
301
  function createWorkflowPlanDenyPolicy() {
300
302
  return {
301
303
  check(req) {
302
- return req.toolName === RUN_WORKFLOW_TOOL_NAME
304
+ return heldRuleName(req, isWorkflowName) !== undefined
303
305
  ? { action: "deny", message: `Plan mode is active — "${req.toolName}" is a write/mutating tool and is read-only-blocked.`, decisionReason: "rule" }
304
306
  : { action: "allow" };
305
307
  },
@@ -1,10 +1,11 @@
1
1
  import type { HostDecidedBy, HostSettledBy } from "./host-decision.js";
2
2
  import type { IncomingMessage } from "node:http";
3
- import { type AskRequest, type AskOutcome, type CheckpointGate, type PendingAction, type PersistedRuleMandate, type ReadRootGrantCandidate, type RuleOffer } from "@sema-agent/core";
3
+ import { type AskClass, type AskRequest, type AskOutcome, type CheckpointGate, type PendingAction, type PersistedRuleMandate, type ReadRootGrantCandidate, type RuleOffer } from "@sema-agent/core";
4
4
  import { type CardRulePersisted, type RuleConsentLane } from "./rules-consent.js";
5
5
  import { type DenialLimitFallback, type RuleOffersAbsence, type ApprovalRequestFrame, type ApprovalRevokeFrame, type RuleEvidenceProjection } from "./approval-card.js";
6
6
  import { type ApprovalAskStore, type AskRow } from "./plugins/approval-ask-store-sql.js";
7
7
  import type { ApprovalAskAuditSink } from "./approval-ask-audit-store.js";
8
+ import type { ParkCommitWatch } from "./plugins/park-commit-watch.js";
8
9
  import { type GovernanceAskMarks } from "./governance-ask-marks.js";
9
10
  import { type GovernancePosture } from "./runtime-governance.js";
10
11
  /** [ref]:审批 gate kind 闭集(core gateMatch 的 `human`/`irreversible_ask` ↔ outcome.gate "policy_ask")。
@@ -284,6 +285,19 @@ export interface ToolApprovalFrame {
284
285
  * ask」,治理策略产的与普通策略产的在这一个词上同形 ⇒ 顶替不了(逐字论证见 `governance-ask-marks.ts` 顶注)。
285
286
  */
286
287
  origin?: string;
288
+ /**
289
+ * **S-934 ②(core 7.37.0 #1214;ADDITIVE,`"tool_approval"` only)** —— 这一问的**类**:`classes` 的首员(core `ASK_CLASSES` 序),
290
+ * `MODE_ARBITRATION` 的行键(权限模式读的就是它 / 它们)。引擎在每一个铸 `origin` 的站盖章(策略自报的值在盖章处被替换),本仓经
291
+ * `approval-card.ts` 的 `readAskClassPair(req)` 结构窄读(成员筛 = core `isAskClass`,本仓不抄词表;在场却读不出 ⇒ 两键都不投 + 计一次
292
+ * S-249 集外 tag)。**与 {@link classes} 同在同缺**;缺席 = 老引擎 / 不盖类的站(共享制品读确认,core KL-1214-a),不是「无类」。
293
+ * display:壳按类渲卡(cli 卡面、client-core CC-272),durable 审批席按类答;**本仓不按它裁决**(无人值守腿的按类作答读 core 交来的
294
+ * `AskRequest.classes` 本身)。同一对的其余面:`card_json` 卡面 / `approval_request` 呈卡帧 / 断连重放帧(同一份素材)、运维队列行
295
+ * (`parkRowFacts`,读停车行孪生座 `PendingAction.tool_approval.askClass`);inbox 行不带(core `CheckpointSummary` 不回显)。
296
+ */
297
+ askClass?: AskClass;
298
+ /** S-934 ②:这一问属于的**全部**类(core `ASK_CLASSES` 序,`classes[0] === askClass`,非空)—— 读整只集合,别只读 {@link askClass}(一问可同属
299
+ * 几类)。与 `askClass` 同在同缺、同一只窄读;逐字同序过境,不排序不去重。 */
300
+ classes?: AskClass[];
287
301
  /**
288
302
  * **[ref]**(core 7.19.0;**ADDITIVE**,`"tool_approval"` only)—— 这只**根外读** ask 的**清障目录**:
289
303
  * 把 `dir` 加进本会话的读目录(壳侧既有的 `/add-dir` 口),同一条调用就不会再问。
@@ -1126,6 +1140,15 @@ export declare class ToolApprovalCoordinator {
1126
1140
  /** S-877 候裁 16 → A:耐久会话授权店的**读面**(`/decide` 的 `remember:"session"` 写、本席读)。在场 ⇔ 部署有 store backend
1127
1141
  * (装配点 `boot/coordinators.ts`,与 `/decide` 写的是**同一只**店)。读口唯一 = {@link standingGrantSettles}。 */
1128
1142
  private readonly sessionGrants;
1143
+ /** S-929:有序关停已开始(`drainForShutdown` 锁存,不复位 —— 进程正在退出)。之后登记的每一张卡在登记那一刻按关停收场。 */
1144
+ private shutdownDraining;
1145
+ /** S-929:此刻在 `askBroadcast` 里的 ask 数(进出成对,见那里的 try/finally)—— 关停等待「还没登记进 `pending` 的卡」的计数。 */
1146
+ private asksInFlight;
1147
+ /** S-929 F1:park 提交的观察面(checkpoint 店的装饰层;在场 ⇔ 本部署有 park 设施,装配点 `boot/stage-06-runners.ts`)。 */
1148
+ private readonly parkCommits;
1149
+ /** S-929 F1:有序关停下路由到 park、**处置尚未被观察到**的卡 —— 审计 settle 行在这里候着({@link settleParksAwaitingCommit})。
1150
+ * 只在关停期有成员(进程随后退出,不需要回收)。 */
1151
+ private readonly parksAwaitingCommit;
1129
1152
  constructor(opts?: {
1130
1153
  ttlMs?: number;
1131
1154
  /** [ref](案A):零活流铸造的 ask 的窗(见 {@link unreachedTtlMs})。装配点在协议**上场**时才传
@@ -1155,6 +1178,8 @@ export declare class ToolApprovalCoordinator {
1155
1178
  askAudit?: ApprovalAskAuditSink;
1156
1179
  /** S-877 候裁 16:耐久会话授权店的读面(见 {@link ToolApprovalCoordinator.sessionGrants})。 */
1157
1180
  sessionGrants?: SessionGrantReader;
1181
+ /** S-929 F1:park 提交的观察面(见 {@link ToolApprovalCoordinator.parkCommits})。 */
1182
+ parkCommits?: ParkCommitWatch;
1158
1183
  });
1159
1184
  /**
1160
1185
  * [ref] R-13 C —— 「**无人可答**」这一类终局的**唯一**成形口(park 路由 vs deny 政策)。
@@ -1309,6 +1334,54 @@ export declare class ToolApprovalCoordinator {
1309
1334
  * 流没了,值相等即目标集;极端复用形下多转的那条也只是从流内卡变 durable 卡,方向保守。
1310
1335
  * 返回触发条数(0 = 无流内未决 ask,调用方照旧 abort)。幂等:已结算条目跳过,重复调用无害。 */
1311
1336
  parkOpenAsksForTask(wireTaskId: string): number;
1337
+ /**
1338
+ * 🔴 **S-929 —— 有序关停的审批面收尾(一只;`boot/shutdown.ts` 的 `hardShutdown` 起点调,SIGTERM / SIGINT / SIGHUP 三条停止路同一处)。**
1339
+ *
1340
+ * 病(cli [ref] S7):修前关停序不碰协调器 ⇒ 本进程握着的活卡在下一次 boot 被审计收敛器标成 `crashed_before_park`,
1341
+ * 用户屏上看到「crashed」而实为有序重启。
1342
+ *
1343
+ * 三步:
1344
+ * ① 本进程握着的**每一张**未决卡(可达 / 悬挂 / 委派子代,不挑)按**断连同路**收场 —— {@link PendingApproval.forceParkNow}(park 路由;
1345
+ * `deny` 政策下是部署政策拒;有持久 ask 店时走同一把终局 claim)。有序关停与断连是同一件事:投递面要没了,**不是**有人拒了
1346
+ * (不交裸 `false`,S-689 同律)。审计 settle 行携 `cause:"shutdown_before_park"`(成因在 `settle` 咽喉读锁存位,F10);
1347
+ * ② 在 `deadlineMs` 之前等:未决卡结清、在飞腿闭合(闭了的腿照常落 `leg_closed`)、**本次路由到 park 的卡全部结清**
1348
+ * (F1:观察到 core 已处置这次调用 —— 停车行提交或承载它的腿退场 ⇒ 那张卡的审计 settle 行此刻才写,见 {@link settleParksAwaitingCommit})、
1349
+ * 店里在飞的 `put` 静默;
1350
+ * ③ 到期:仍未闭的腿 ⇒ 审计落 `interrupted_by_shutdown` 记号(下一次 boot 收敛照样列出它,decided 臂的 `resumeSafe:false` 提醒不丢);
1351
+ * 仍未提交的 park ⇒ settle 行**不写**(账本上仍是 pending 孤儿 ⇒ 下一次 boot 与崩溃同路收敛,证据不丢),交回调用方
1352
+ * (`uncommittedParks`,运维 warn `approval_park_uncommitted_at_shutdown` 由关停序落 —— 它握 logger)。之后才观察到的照样写 settle 行
1353
+ * (观察到就是结清,迟到也是)。
1354
+ * **一条规则覆盖先后两批卡**(codex r1 F2):收尾一开始就锁存 {@link shutdownDraining};此后**登记**的卡(收尾开始时还在 `ensureAsk`
1355
+ * 的 await 里 —— 零活流的后台 / 委派子代也在内)在登记那一刻按同一条路收场(`askBroadcast` 的铸造审计点之后读这一位)。等待条件
1356
+ * 因此数「在飞的 ask」({@link asksInFlight},`askBroadcast` 进出计数)而不只是 `pending` —— 尚未登记的卡不在 `pending` 里。
1357
+ * `deadlineMs` 是绝对时刻(`Date.now()` 域):关停序对审批面与店 quiesce 读**同一个**到期点(一个宽限窗,不是两个)。
1358
+ * 从不抛错(关停序不许被审计面卡住);再调一次 = 再等一次(记号有去重集,零重写)。
1359
+ */
1360
+ drainForShutdown(deadlineMs: number): Promise<{
1361
+ uncommittedParks: Array<{
1362
+ approvalId: string;
1363
+ taskId: string;
1364
+ }>;
1365
+ }>;
1366
+ /**
1367
+ * S-929 F1:checkpoint 店的观察层报「一只审批停车行提交了」({@link CommittedParkCalls}:行自己陈述的会话与握着的调用)⇒ 候着的
1368
+ * **同一次调用**结清:执行会话**相等**(候者记的是 ask 自己陈述的发起会话,行陈述的是它停住的会话 —— core 同一个变量)∧ 调用 id ∈ 行握着的调用。
1369
+ * 不做「会话相交」:调用 id 只在 run 内唯一(父子各自的第一只调用常同名),宿主会话若作子代的等价别名,宿主停车行一提交就会冒充子代的提交(车 SO)。
1370
+ * 只在关停期有候者;非关停期的提交一个不碰。
1371
+ * 🔴 观察层的订阅者契约是**不得抛**(它跑在 `put` 已提交之后、返回 core 之前 —— 抛了会把一次已提交的 park 报成失败),见下面的写行口。
1372
+ */
1373
+ private noteParkCommitted;
1374
+ /**
1375
+ * S-929 F1 的**结清口**(一处):有序关停下 park 路由的卡,在**观察到 core 已处置这次调用**时写它的审计 settle 行 —— 两种观察,同一件事实:
1376
+ * · `park-committed` —— 停车行提交了(观察层看到 `put` 落定,或 core 回读确认的那一次 `get` 读到了行);
1377
+ * · `leg-closed` —— **承载它的那条腿**退场了(`runWithContext` finally 按 ctx 身份配 {@link ParkAwaitingCommit.carrierLeg}:core 已走完这条腿 ——
1378
+ * 停靠了,或拒了停靠 fail-closed 继续)。
1379
+ * 两者都没观察到就退出 ⇒ 这行不写,与崩溃同路(下一次 boot 列 pending 孤儿)。委派 / 后台子代与零活流的卡没有被追踪的承载腿,只有第一种观察
1380
+ * (子代的出处是宿主:宿主腿退场不是子代这次调用的处置 —— 车 SO)。
1381
+ * 写行的 sink 自吞失败;sink 实现**自身**抛的那一形在这里兜住并按 [ref] 留痕,同 tag 扩射程(`http/server.ts` `noteLateSettleIfAccepted`
1382
+ * 同一先例):丢的只是这一条审计行 —— 下一次 boot 它被当 pending 孤儿列出(证据方向),park 行本身不受影响。
1383
+ */
1384
+ private settleParksAwaitingCommit;
1312
1385
  /** 测试/可观测性钩子(X-2):某 (taskId) 维当前占用的准入名额数。 */
1313
1386
  admittedCount(taskId: string): number;
1314
1387
  /** [ref] 车2(D5 store 故障姿势):任何 store 调用 throw ⇒ fail-open 到进程内机械照旧(store 是记账/
@@ -1380,23 +1453,31 @@ export declare class ToolApprovalCoordinator {
1380
1453
  sessionId?: string;
1381
1454
  }) => ((req: AskRequest) => Promise<AskOutcome>);
1382
1455
  /**
1383
- * 🔴 session grant 短路前的**持久出处复核**([ref] 的 codex R1-[high] 补丁;[ref] 换判据)。
1456
+ * 🔴 session grant 短路前的**持久出处复核**([ref] 的 codex R1-[high] 补丁;[ref] 换判据;S-944 / SN-C1 换读口)。
1457
+ *
1458
+ * 回 `true` = 「这次调用在这条腿上从来没有过持久出处」⇒ 一揽子放行可以短路;
1459
+ * 回 `false` = 这次调用在腿内有行(任何状态、任何 ask 身份),**或**读不出来(不知道 = 不放宽)⇒ 调用方必须往下走真出卡。
1460
+ *
1461
+ * 为什么只读不写:短路的全部价值就是「不铸卡、不落行、不占 pending」,做一次按调用坐标的读保住这个价值,
1462
+ * 而 `ensureAsk` 会写行。为什么一行都没有就放行:这次调用在这条腿上的行只可能由**这次调用的某一问**铸出来,
1463
+ * 一行都没有 ⇒ 从来没有过一份能反驳本地判据的持久出处(这也是常见形:grant 命中的调用多半是头一次到)。
1384
1464
  *
1385
- * 回 `true` = 「这只 ask 从来没有过持久出处」⇒ 一揽子放行可以短路;
1386
- * 回 `false` = 行在(任何状态),**或**读不出来(不知道 = 不放宽)⇒ 调用方必须往下走真出卡。
1465
+ * ## 争议按**调用**判,不按 ask 身份判(S-944 / SN-C1)
1387
1466
  *
1388
- * 为什么只读不写:短路的全部价值就是「不铸卡、不落行、不占 pending」,做一次主键点读保住这个价值,
1389
- * 而 `ensureAsk` 会写行。为什么行不在就放行:确定性 askId 的行只可能由**这只 ask 自己**铸出来,
1390
- * 行不在 ⇒ 从来没有过一份能反驳本地判据的持久出处(这也是常见形:grant 命中的 ask 多半是头一次到)。
1467
+ * 争议的真源是调用:人对这次调用说过什么、这次调用还有没有一张卡挂着。ask 身份(`deriveAskId`)只是其中**一问**的主键,而身份会加轴 ——
1468
+ * S-944 把这一问的类集并进身份后,按身份点读会把两类行读成「不存在」:① 同一调用另一类集的那一问(core #1218 的委派第二张卡与首卡);
1469
+ * ② 升级前按旧派生写的行(同一 resume token 重投 = 同一 `legKey`,跨版本重入同一调用)。两形都让一揽子放行代答、人拒 / 挂着的卡被绕过
1470
+ * (codex 车内 r1 [high] + [ref] gpt-6 腿 A 同指)。⇒ 读口 = 店的 `listByCall`(四列 = 这次调用在这条腿上的坐标,精确匹配),本函数不派生 askId。
1471
+ * **身份可以加轴,争议判据不得依赖身份。**
1391
1472
  *
1392
1473
  * ## 判据的单一原则:**短路不得产生与「没有 grant 时那条路」不同的终局**
1393
1474
  *
1394
- * 没有 grant 时,同一条行在下面 `askBroadcast` 里都有确定去处:终局行(DECIDED/PARKED/DENIED/VOID)
1395
- * 走 `replayTerminalAskRow`(deny 回放 `false`、park 回放 `"unavailable"`);活着的 STREAM_PENDING 行走
1396
- * `ensureAsk` 的幂等命中(采信行上的卡与 deadline,等真决议)。短路一旦 return,这两条腿结构上都够不着
1397
- * —— 所以只要行在,短路就是在用一个**更宽**的答案顶替那条路。⇒ 判据 = `row === null`。
1475
+ * 没有 grant 时,这次调用在腿内的任何一行都让下面 `askBroadcast` 有确定去处:同身份的终局行(DECIDED/PARKED/DENIED/VOID)
1476
+ * 走 `replayTerminalAskRow`(deny 回放 `false`、park 回放 `"unavailable"`);同身份的活 STREAM_PENDING 行走 `ensureAsk` 的幂等命中
1477
+ * (采信行上的卡与 deadline,等真决议);别的身份的行(另一类集 / 旧派生)⇒ 本问的身份无行 ⇒ 出卡再问(fail-closed)。短路一旦 return,
1478
+ * 这些腿结构上都够不着 —— 所以只要这次调用在腿内有行,短路就是在用一个**更宽**的答案顶替那条路。⇒ 判据 = `rows.length === 0`。
1398
1479
  *
1399
- * 这条原则是分两次到位的,两次漏的都是「行 = 真源」的一部分:
1480
+ * 这条原则是分几次到位的,每次漏的都是「行 = 真源」的一部分:
1400
1481
  * · **[ref]**(codex R1-[high]):首版只查**本地** peek,行上标治理的那一半漏了 —— 本地标表是
1401
1482
  * 进程内表,failover / 重启后幂等命中既有行时它恒空。
1402
1483
  * · **[ref]**(2026-08-19 三轴组复审,refuter 真运行复现三形):补丁把行读到手里,却只看
@@ -1406,12 +1487,13 @@ export declare class ToolApprovalCoordinator {
1406
1487
  * · **codex 交叉复审**([ref] 同批,验真后采纳):只补终态判据仍漏「卡还在别处挂着」——
1407
1488
  * STREAM_PENDING 行意味着已有一张卡在等人,凭 grant 抢跑会让行上的终局与真实执行长期矛盾
1408
1489
  * (那张卡随后可被人/另一副本决成相反答案,或 TTL 到点被收敛成幻影 gate)。
1409
- * 三次都不是各自独立的 bug,是同一句话没说全,故最终判据收成一条而不是三个分支。
1490
+ * · **S-944 / SN-C1**(见上节):读口按身份点读,身份加轴后漏掉同调用别的问与旧派生的行。
1491
+ * 几次都不是各自独立的 bug,是同一句话没说全,故最终判据收成一条而不是几个分支。
1410
1492
  *
1411
- * 零额外 IO(行已经在手里),且 fail-closed **by construction**:将来 `AskState` 加词也不需要动这里。
1493
+ * 零额外 IO(一次读),且 fail-closed **by construction**:将来 `AskState` 加词、ask 身份再加轴,都不需要动这里。
1412
1494
  *
1413
1495
  * 🔴 **已登记的残余([ref],PARTIAL,本批不修)**:这一步是一次**非原子的 check-then-act** ——
1414
- * 另一副本正在为同一确定性 askId 落行、而本次点读抢在其提交前完成时,本副本仍会短路。真解 = 把出处
1496
+ * 另一副本正在为同一调用落行、而本次按调用坐标的读(`listByCall`)抢在其提交前完成时,本副本仍会短路。真解 = 把出处
1415
1497
  * 判定与 grant 消费合并成**同一次**原子存储操作(带条件 upsert / CAS),属店语义级改动,须本模块属主
1416
1498
  * 立件(与上面 R4-F1/[ref] 两条同源残余同一处置)。「行不在 ⇒ 不短路」不是修法:grant 命中的 ask
1417
1499
  * 多半头一次到,那等于把一揽子放行整只废掉。窗的上界由本判据定死:行**一提交**窗即关。
@@ -1570,6 +1652,8 @@ export declare class ToolApprovalCoordinator {
1570
1652
  * 送达某个客户端之后发生(respond 需要客户端手上有这个 id),这是 outcome==="allowed"/"denied" 与
1571
1653
  * "expired"(TTL/abort/run-exit-cleanup/emit 广播全灭四类共用)的天然分野。 */
1572
1654
  private askBroadcast;
1655
+ /** {@link askBroadcast} 的本体(计数在外层,一处进出)。 */
1656
+ private askBroadcastCounted;
1573
1657
  /** `POST /v1/tool-approvals/:id/respond` — settle a parked approval with the shell's decision. Owner-gated with a
1574
1658
  * 404 (no existence oracle), body validated first (400 is existence-independent) — question/steer parity. The HTTP
1575
1659
  * layer owns auth (gatedPrincipal + REQUIRE_PRINCIPAL) before calling.