@sema-agent/server 7.14.0 → 7.16.0-rc.1

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 (71) hide show
  1. package/MIGRATION.md +16 -1
  2. package/USAGE.md +38 -4
  3. package/dist/approval-ask-machine.d.ts +10 -0
  4. package/dist/approval-ask-machine.js +10 -0
  5. package/dist/approval-card.d.ts +65 -0
  6. package/dist/approval-card.js +54 -6
  7. package/dist/approval-reconciler.d.ts +17 -1
  8. package/dist/boot/memory-boundary.d.ts +6 -0
  9. package/dist/boot/memory-boundary.js +10 -2
  10. package/dist/boot/resolve-spec.js +31 -4
  11. package/dist/boot/runner-deps.d.ts +41 -2
  12. package/dist/boot/runner-deps.js +43 -0
  13. package/dist/boot/session-faces.js +26 -4
  14. package/dist/boot/shutdown.js +17 -0
  15. package/dist/boot/stores.js +28 -7
  16. package/dist/capabilities/memory-notice.d.ts +13 -9
  17. package/dist/capabilities/memory-notice.js +35 -15
  18. package/dist/config-center/apply-effective.d.ts +14 -0
  19. package/dist/config-center/apply-effective.js +81 -1
  20. package/dist/config-types.d.ts +28 -2
  21. package/dist/config.js +38 -2
  22. package/dist/fleet/fleet-terminal-window.d.ts +12 -0
  23. package/dist/fleet/fleet-terminal-window.js +27 -4
  24. package/dist/http/active-run-conflict.d.ts +41 -1
  25. package/dist/http/active-run-conflict.js +24 -10
  26. package/dist/http/routes/approvals-assistant.d.ts +11 -3
  27. package/dist/http/routes/approvals-assistant.js +97 -20
  28. package/dist/http/routes/capabilities.js +20 -1
  29. package/dist/http/routes/diagnostics.d.ts +18 -0
  30. package/dist/http/routes/diagnostics.js +26 -0
  31. package/dist/http/routes/runs.js +86 -19
  32. package/dist/http/routes/side-query.js +15 -1
  33. package/dist/http/routes/tasks.js +32 -3
  34. package/dist/http/routes/trace-usage.js +38 -37
  35. package/dist/http/server.js +67 -13
  36. package/dist/leader/fanout.d.ts +18 -0
  37. package/dist/leader/fanout.js +34 -1
  38. package/dist/leader/leader.js +9 -5
  39. package/dist/leader/wire.js +16 -5
  40. package/dist/main.js +1 -0
  41. package/dist/memory-scope.d.ts +20 -0
  42. package/dist/memory-scope.js +45 -0
  43. package/dist/model-select.d.ts +43 -1
  44. package/dist/model-select.js +70 -2
  45. package/dist/observability/fail-open.d.ts +8 -0
  46. package/dist/observability/fail-open.js +8 -0
  47. package/dist/observability/metrics.js +7 -1
  48. package/dist/parent-watch.d.ts +57 -0
  49. package/dist/parent-watch.js +108 -0
  50. package/dist/plugins/checkpoint-store-sql.d.ts +67 -0
  51. package/dist/plugins/checkpoint-store-sql.js +133 -7
  52. package/dist/plugins/local-checkpoint-store.js +11 -1
  53. package/dist/plugins/memory-embedder-fingerprint.d.ts +119 -0
  54. package/dist/plugins/memory-embedder-fingerprint.js +280 -0
  55. package/dist/plugins/permission-rule-store-sql.d.ts +0 -3
  56. package/dist/plugins/permission-rule-store-sql.js +1 -7
  57. package/dist/plugins/pg-pool.js +3 -0
  58. package/dist/plugins/store-backend.d.ts +5 -6
  59. package/dist/plugins/store-backend.js +4 -1
  60. package/dist/plugins/store-contracts.d.ts +17 -0
  61. package/dist/plugins/store-contracts.js +33 -0
  62. package/dist/plugins/tidb-pool.js +7 -0
  63. package/dist/plugins/tool-result-store-sql.d.ts +18 -13
  64. package/dist/plugins/tool-result-store-sql.js +50 -29
  65. package/dist/run-local.js +1 -0
  66. package/dist/tool-approval.d.ts +10 -0
  67. package/dist/tool-approval.js +143 -9
  68. package/dist/trace/project.js +8 -0
  69. package/dist/trace/redact.d.ts +14 -1
  70. package/dist/trace/redact.js +14 -2
  71. package/package.json +3 -3
@@ -14,6 +14,7 @@
14
14
  * test/session-active-conflict-materials.test.ts 的分门用例会红。认不出的 kind ⇒ null(诚实缺席,不铸假门)。
15
15
  */
16
16
  import { type Autonomy } from "../runtime-governance.js";
17
+ import { type RunRecord } from "../plugins/store-contracts.js";
17
18
  /** #220 归因的部署侧入参(两个治理旋钮;判据属主见 `governanceMandatesShellGateAlways`)。 */
18
19
  export interface GovernancePosture {
19
20
  autonomy?: Autonomy;
@@ -50,6 +51,42 @@ export declare function resumeEntryForGate(kind: string, ids: {
50
51
  sessionId: string;
51
52
  taskId: string;
52
53
  }): string | null;
54
+ /**
55
+ * #220:推「这道门是运维治理层下的吗」。判据是**合取**,两半各管一件事:
56
+ *
57
+ * 行上的取证格 `gate.riskDescriptor.shellGateDoctrine === "always"`
58
+ * ∧ 本部署的治理层本来就要求 always(`governanceMandatesShellGateAlways`,单一判据属主在
59
+ * `runtime-governance.ts`,那里写着为什么单看行不够 —— SUP 路由姿态同样产 `"always"`)
60
+ *
61
+ * 第一半:core 在 mint 这道门时把「当时活着的 doctrine」如实写进 `riskDescriptor.shellGateDoctrine`
62
+ * (2026-08-05 取证裁定;`prepare-task` 只在**被 shell 门铸**的 ask 上写它),所以持久行自己带着证据 ——
63
+ * park 腿天然跨副本、跨重启,活卡腿那张 ALS 标记表在这里结构上够不着。
64
+ * 第二半:把「有效档 always」收窄到「治理层是真成因」,否则一条 SUP 路由出来的门会被谎报成治理强制。
65
+ *
66
+ * ⚠️ 残留(双向失真的窄在场向 + 根治路径)登记在 `governanceMandatesShellGateAlways` 的顶注,别在这里
67
+ * 复述;本位只承担「分诊提示」的分量,不参与任何门/CAS/resume 判定。
68
+ *
69
+ * 🔴 `"classify"` 档**刻意不标**:那一档由 core 的分类器逐调用裁决,一次 shell ask 可能出自分类器(治理)
70
+ * 也可能出自 `APPROVAL_REQUIRE` 这类别的门,行上无从分辨 —— 与活卡腿同一条「宁缺毋假」纪律。
71
+ *
72
+ * 🔴 **导出的理由 = 单一属主**([3684]②):这条推法此前只服务 409 体,于是 `GET /v1/approvals` /
73
+ * `/v1/approvals/stream` 两条 durable 读面的行上**没有**出身格 —— 同一道 park 门,从 409 看得见出身、
74
+ * 从运维队列看不见([3683]-2 实测)。补法**不是**在读面另写一份判据(两份必漂,而这一位恰恰是靠
75
+ * 「行上取证 ∧ 部署姿态」的合取才不谎报的),而是让三条面共用本函数。
76
+ *
77
+ * ⚠️ **askId 为什么不在这里**(#220 的另一半,如实记账):待批 ask 的 `askId` 只活在 `approval_ask` 行上
78
+ * (checkpoint 行没有这一列,core 的 `CheckpointGate`/`PendingAction`/`Checkpoint` 顶层都不带它)。反向
79
+ * (checkpoint → ask)今天**没有读口**:两条 list 读口都硬过滤 `state='STREAM_PENDING'`,而 park 完的行是
80
+ * `PARKED`;唯一精确的连接键是 ask 行上的 `gate_token`,要按它反查得给店加一个新读口(SQL 面 = 双库集成
81
+ * 门)。派生 `deriveAskId(...)` 也不行:它吃 `legKey`(= 上一腿 resume token 的摘要)与 `parentToolCallId`,
82
+ * 这两维从 checkpoint 行推不出来,二腿/子代形上会算出一个**错**的 id —— 在「等的谁」这条通道上,错 id
83
+ * 比缺席坏得多(本文件 `resumeEntryForGate` 的 default 臂是同一条纪律)。故本批诚实缺席。
84
+ */
85
+ export declare function governanceOriginOf(gate: {
86
+ riskDescriptor?: {
87
+ shellGateDoctrine?: "classify" | "always";
88
+ };
89
+ } | undefined, governance: GovernancePosture | undefined): true | undefined;
53
90
  /** token 泛型:真身是 branded CheckpointToken(秘密能力,只在本函数内部流转,绝不进响应体)。 */
54
91
  /**
55
92
  * SSE 车道的 done 帧 result(拒绝形)。与 409 body **同一铸体处** —— 此前 tasks.ts 在 res.write 里
@@ -67,9 +104,12 @@ export interface ActiveRunConflictDoneResult {
67
104
  }
68
105
  export declare function toDoneFrameResult(body: ActiveRunConflictBody): ActiveRunConflictDoneResult;
69
106
  interface ConflictProbeDeps<TToken> {
107
+ /** 结构形入参,但 `status` 取 run 行的**闭词表**(合并码重扫):判 park 要走词表属主
108
+ * `isParkedRunStatus`,而它的入参是穷举联合 —— 这一格写成开放 `string` 就只能在调用点裸 cast,
109
+ * 绕开那道编译期执法(core 加一个 park 类成员时本文件不会红)。 */
70
110
  runStore?: {
71
111
  getRun?: (id: string) => Promise<{
72
- status?: string;
112
+ status?: RunRecord["status"];
73
113
  } | null | undefined>;
74
114
  } | undefined;
75
115
  checkpointStore?: {
@@ -14,24 +14,30 @@
14
14
  * test/session-active-conflict-materials.test.ts 的分门用例会红。认不出的 kind ⇒ null(诚实缺席,不铸假门)。
15
15
  */
16
16
  import { governanceMandatesShellGateAlways } from "../runtime-governance.js";
17
+ import { isParkedRunStatus } from "../plugins/store-contracts.js";
17
18
  /** 与 runs.ts/tasks.ts 三个 409 位共享的旧文案(byte-frozen:api-error-text-freeze 门认这句)。 */
18
19
  export const ACTIVE_RUN_CONFLICT_BASE_TEXT = "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)";
19
20
  /** gate.kind → 它的那一个 resume 入口(sessionId/taskId 寻址,无秘密)。 */
20
21
  export function resumeEntryForGate(kind, ids) {
21
22
  switch (kind) {
22
- // tool_approval / policy_ask 不是 core 现役 kind(wire 集=WIRE_GATE_KINDS 六个,type 门双向等值)——
23
- // 这两臂是**耐久历史行容忍**([2373] D 类):旧 core 铸的 checkpoint 行可能带旧 kind 名,店里读出来
24
- // 仍要给出真出路;不是对上游新帧的兼容臂,别当 [2354] 清理对象误删。
25
- case "tool_approval":
23
+ // 🔴 tool_approval / policy_ask 历史臂已撤([2373] D 类容忍的翻案,扫描修复批 2026-08-12):
24
+ // 当年这两臂指 /decide,但 /decide 入口守卫按 APPROVAL_GATE_KINDS 闭集(human/irreversible_ask,
25
+ // A-002.1 单一属主)判 kind,旧 kind 名打过去必 409 gate_not_tool_approval——指一条会拒绝自己的
26
+ // 路径,与 needs_review 臂同「铸假门」病族;且 D-D SLA deny-sweep 的 SQL 取行也只选闭集两 kind,
27
+ // 旧 kind 历史行在系统内已无任何处理臂。诚实缺席(落 default null)> 假指路;若真要救历史行,
28
+ // 正解是扩 APPROVAL_GATE_KINDS 单一属主(连带 SQL IN/守卫/sweep 全族),不是在这里单独铸路。
26
29
  case "human":
27
30
  case "irreversible_ask":
28
- case "policy_ask":
29
31
  return `/v1/approvals/${ids.sessionId}/decide`;
30
32
  case "plan_review":
31
- // needs_review 与 plan_review 同入口(TASKS-RUNS-8 附注,[2400] 审计补臂):routes/tasks.ts 的
32
- // needs_review park 注明文「resumable via /plan_review」——此前缺臂 ⇒ 409 体 pendingGate 丢指引。
33
- case "needs_review":
34
33
  return `/v1/assistant/tasks/${ids.taskId}/plan_review`;
34
+ // 🔴 needs_review **gate kind** 刻意无臂([3683]-1 属主定谳,2026-08-12,翻转 [2400] 补臂):
35
+ // core 三向拆分里 {kind:"needs_review"}(dry-run 拦截)的合法 outcome=dry_run_review,而 server
36
+ // 生产路由没有 dry_run_review 决议端点、/plan_review 端点守卫会 409 gate_not_plan_review 拒它
37
+ // ——旧映射把消费端指到一个会拒绝它的端点(铸假门)。[2400] 当年引的 tasks.ts 注明文说的是
38
+ // run **status** needs_review(plan_review/dry_run 两 gate 共享该 status),被误当 gate **kind**
39
+ // 语义抄进本表。落 default null=诚实缺席(客户端仍有 activeTaskStatus + cancel 保底真路);
40
+ // 将来若接 dry-run/shadow profile 的决议端点,在此补臂并同步 wire 契约。
35
41
  case "resource_limit":
36
42
  return `/v1/assistant/tasks/${ids.taskId}/resume`;
37
43
  case "task_done":
@@ -58,6 +64,11 @@ export function resumeEntryForGate(kind, ids) {
58
64
  * 🔴 `"classify"` 档**刻意不标**:那一档由 core 的分类器逐调用裁决,一次 shell ask 可能出自分类器(治理)
59
65
  * 也可能出自 `APPROVAL_REQUIRE` 这类别的门,行上无从分辨 —— 与活卡腿同一条「宁缺毋假」纪律。
60
66
  *
67
+ * 🔴 **导出的理由 = 单一属主**([3684]②):这条推法此前只服务 409 体,于是 `GET /v1/approvals` /
68
+ * `/v1/approvals/stream` 两条 durable 读面的行上**没有**出身格 —— 同一道 park 门,从 409 看得见出身、
69
+ * 从运维队列看不见([3683]-2 实测)。补法**不是**在读面另写一份判据(两份必漂,而这一位恰恰是靠
70
+ * 「行上取证 ∧ 部署姿态」的合取才不谎报的),而是让三条面共用本函数。
71
+ *
61
72
  * ⚠️ **askId 为什么不在这里**(#220 的另一半,如实记账):待批 ask 的 `askId` 只活在 `approval_ask` 行上
62
73
  * (checkpoint 行没有这一列,core 的 `CheckpointGate`/`PendingAction`/`Checkpoint` 顶层都不带它)。反向
63
74
  * (checkpoint → ask)今天**没有读口**:两条 list 读口都硬过滤 `state='STREAM_PENDING'`,而 park 完的行是
@@ -66,7 +77,7 @@ export function resumeEntryForGate(kind, ids) {
66
77
  * 这两维从 checkpoint 行推不出来,二腿/子代形上会算出一个**错**的 id —— 在「等的谁」这条通道上,错 id
67
78
  * 比缺席坏得多(本文件 `resumeEntryForGate` 的 default 臂是同一条纪律)。故本批诚实缺席。
68
79
  */
69
- function governanceOriginOf(gate, governance) {
80
+ export function governanceOriginOf(gate, governance) {
70
81
  if (gate?.riskDescriptor?.shellGateDoctrine !== "always")
71
82
  return undefined;
72
83
  return governanceMandatesShellGateAlways(governance ?? {}) ? true : undefined;
@@ -92,7 +103,10 @@ export async function buildActiveRunConflict(deps, sessionId, activeTaskId) {
92
103
  try {
93
104
  const row = await deps.runStore?.getRun?.(activeTaskId);
94
105
  const status = row?.status;
95
- if (status !== "running" && status !== "suspended" && status !== "needs_review")
106
+ // 判据取词表属主(合并码重扫):park 是两个词,由 `isParkedRunStatus` 的穷举 switch + `never` 臂看管;
107
+ // 手抄词表绕开那道编译期执法(core 新增一个 park 类成员时这里会静默判「非 park」⇒ 409 体少 activeTaskStatus
108
+ // 与 pendingGate 两格指路材料)。`running` 分支照旧单列(它不是 park,文案与出路都不同)。
109
+ if (status === undefined || (!isParkedRunStatus(status) && status !== "running"))
96
110
  return base;
97
111
  if (status === "running") {
98
112
  return {
@@ -14,6 +14,7 @@
14
14
  import type { IncomingMessage, ServerResponse } from "node:http";
15
15
  import type { QuestionAnswer } from "@sema-agent/core";
16
16
  import type { CheckpointStoreFull } from "../../plugins/store-backend.js";
17
+ import { type GovernancePosture } from "../active-run-conflict.js";
17
18
  import type { RouteCtx } from "../route-ctx.js";
18
19
  export declare const ASSISTANT_PREEMPT_RE: RegExp;
19
20
  export declare const ASSISTANT_RESUME_RE: RegExp;
@@ -30,8 +31,12 @@ export declare function handleApprovalsAssistant(req: IncomingMessage, res: Serv
30
31
  * for a decided/expired/gone one. Cross-replica BY CONSTRUCTION — the poll reads the SHARED checkpoint table,
31
32
  * so a suspend on ANY replica is seen by an operator streaming on a DIFFERENT replica. A transient DB blip
32
33
  * heartbeats + retries (never kills the stream); a 15-min cap + req-close end it (parity with streamTaskTrace).
33
- * Poll-granularity caveat: a pending that resolves AND re-suspends on the same (session,toolCallId) within one
34
- * interval shows no delta acceptable (the live snapshot is always eventually correct; no decision is missed).
34
+ * Poll-granularity caveat (**narrowed** by [3684]②): the delta now re-emits a `pending` frame whenever a key's
35
+ * WIRE PROJECTION changes (upsert), not only when the key enters so a same-key re-suspend within one interval
36
+ * IS visible as long as anything on the row differs. What still shows no delta is the strictly degenerate case:
37
+ * a resolve + re-suspend on the same (session,toolCallId) whose projection is BYTE-IDENTICAL — indistinguishable
38
+ * by construction, and harmless (the card the operator sees is the card that is pending). Pins: the two halves of
39
+ * `test/wire-pairing-approvals-stream.test.ts` S3b (changed ⇒ re-emit with the NEW payload; unchanged ⇒ silence).
35
40
  *
36
41
  * A-002.19(2026-08-09 亲验定性=设计接受,非欠账):每连接自跑 pollMs 轮询打共享 checkpoint 表,
37
42
  * 之所以不建 fan-out/单轮询器基建——①消费方=portal 运营面,并发连接数量级为个位(不是用户面);
@@ -40,5 +45,8 @@ export declare function handleApprovalsAssistant(req: IncomingMessage, res: Serv
40
45
  * 数假设若被打破(portal 多开成常态),届时把 poll 收敛为进程内单轮询器多路复用——那是量级触发的
41
46
  * 演化,不是现在的缺陷。
42
47
  */
43
- export declare function streamApprovals(req: IncomingMessage, res: ServerResponse, cs: Pick<CheckpointStoreFull, "listPending">, scope: string | undefined, pollMs?: number): Promise<void>;
48
+ export declare function streamApprovals(req: IncomingMessage, res: ServerResponse, cs: Pick<CheckpointStoreFull, "listPending">, scope: string | undefined,
49
+ /** [3684]②:出身归因的部署侧一半(缺席 ⇒ 归不出治理出身 ⇒ 键缺席,与 best-effort 同方向)。**位置在
50
+ * `pollMs` 之前**是刻意的:`pollMs` 只有测试传,而本参数是每个生产调用点都必须给的那一个。 */
51
+ governance?: GovernancePosture, pollMs?: number): Promise<void>;
44
52
  //# sourceMappingURL=approvals-assistant.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { isParkedRunStatus } from "../../plugins/store-contracts.js";
1
2
  import { principalFrom, decodeCheckpointScope, PRINCIPAL_TOKEN_HEADER, APPROVAL_MAC_HEADER, APPROVAL_MAC_KID_HEADER } from "../../security.js";
2
3
  import { verifyDirectDoorProof } from "../../principal-jwt.js";
3
4
  import { MAX_APPROVAL_REASON_CHARS } from "../../approval-hmac.js";
@@ -6,6 +7,7 @@ import { fleetRunLabels } from "../../fleet/fleet-bus.js"; // [2069]④ §3 行
6
7
  import { sleep } from "../sse-log.js";
7
8
  import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_MS } from "../send.js";
8
9
  import { gatedPrincipal, explicitOperatorOk, isOperator } from "../principal-gate.js";
10
+ import { governanceOriginOf } from "../active-run-conflict.js";
9
11
  // design/80 seam #2 (assistant-scheduler): graceful preempt (durable yield) + resource_limit resume of one task.
10
12
  export const ASSISTANT_PREEMPT_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/preempt$/;
11
13
  export const ASSISTANT_RESUME_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/resume$/;
@@ -61,16 +63,17 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
61
63
  const scope = operator
62
64
  ? (new URL(req.url ?? "", "http://x").searchParams.get("owner") ?? undefined)
63
65
  : (principal ?? "__none__");
64
- await streamApprovals(req, res, cs, scope);
66
+ await streamApprovals(req, res, cs, scope, deps.config);
65
67
  return;
66
68
  }
67
69
  if (req.method === "GET" && url === "/v1/approvals") {
68
70
  const scope = operator
69
71
  ? (new URL(req.url ?? "", "http://x").searchParams.get("owner") ?? undefined) // operator: all (or ?owner)
70
72
  : (principal ?? "__none__"); // non-operator: only its own scope (never others' pending)
71
- // #209 件4:行整只上 wire(键集契约不变),但 `riskDescriptor.shadowedRule` 这一格先脱敏 ——
72
- // 理由与 `/v1/approvals/stream` 共用同一个投影函数,见 `redactPendingDisclosures` 顶注。
73
- sendJson(res, 200, { pending: (await cs.listPending(scope)).map(redactPendingDisclosures) });
73
+ // #209 件4 + [3684]②:行整只上 wire(键集契约照旧「整只透传、不按键投影」),但两格由投影函数
74
+ // 负责——`riskDescriptor.shadowedRule` 脱敏、`governanceForced` 归因。与 `/v1/approvals/stream`
75
+ // **共用同一个** `projectPendingForWire`(两条读面各投各的必漂,见其顶注)
76
+ sendJson(res, 200, { pending: (await cs.listPending(scope)).map((r) => projectPendingForWire(r, deps.config)) });
74
77
  return;
75
78
  }
76
79
  // exemptions surface — the UI's "本会话不再询问" state (list) + revoke. Same authz shape as
@@ -214,7 +217,8 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
214
217
  // so a running/suspended-only filter DROPPED exactly the HITL-gated tasks §3 promises to surface "needs-
215
218
  // attention first" — the operator could never discover the very plan_review tasks §4c says to resolve.
216
219
  // Same class as the inbox leak (handler vs contract drift). needs_review is a non-terminal park → include it.
217
- .filter((r) => r.status === "running" || r.status === "suspended" || r.status === "needs_review")
220
+ // 判据取词表属主(合并码重扫):park 两个词由 `isParkedRunStatus` 穷举,手抄形对 core 加词无编译期钉。
221
+ .filter((r) => r.status === "running" || isParkedRunStatus(r.status))
218
222
  .map((r) => {
219
223
  const s = byGate.get(r.sessionId);
220
224
  const gate = s ? { kind: s.gateKind, severity: s.severity ?? null, spentMicroUsd: s.spentMicroUsd ?? null, deadline: s.deadline ?? null } : null;
@@ -313,17 +317,43 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
313
317
  }
314
318
  // Durable flag FIRST (only flips a still-running row), abort after — if the run suspended/terminal'd in
315
319
  // the getRun→here window the flag affects 0 rows, so re-read and answer by the ACTUAL state.
316
- const flagged = await deps.runStore.requestPreempt(taskId, run.owner); // owner guard: single-DB defense-in-depth (owner-gate already enforced above)
317
- if (!flagged) {
320
+ let flagged = await deps.runStore.requestPreempt(taskId, run.owner); // owner guard: single-DB defense-in-depth (owner-gate already enforced above)
321
+ // 🔴 抢跑输了(旗打空 0 行) 按 run 的**真实**状态**三向**重判(重扫二轮红先修;与 `runs.ts` 取消腿
322
+ // 的孪生臂 `CANCEL_RECLASSIFY_ATTEMPTS` 那一段同源同形)。旧形无论重读到什么都发 202 no-op,于是
323
+ // **同一条 park 行**:不经这个竞态窗时回 409 `conflict.already_suspended`(前置检查那一支,`403a841`),
324
+ // 经过它时回 202「task no longer running」。按状态码分支的调度器对同一个状态拿到两种答案,而契约文
325
+ // §4d 把它写成了绝对规则(parked ⇒ 409 / genuinely terminal ⇒ 202),只成文了其中一种。
326
+ // · **park**(判据取词表属主 `isParkedRunStatus`,穷举 switch + never 臂)⇒ 409,`status` 带**行的真词**;
327
+ // · **running**(park→running 二次跃迁真实存在:并发 approve 的恢复腿把行翻回来)⇒ **重挂旗**,
328
+ // 有界重试;行活过来正是该重挂旗的时刻,不是该撒谎的时刻;
329
+ // · 真终局 ⇒ 照旧幂等 202 no-op(带真实状态)。
330
+ // 有界(park↔running 反复跳时不许把请求钉在这):次数用尽仍 running ⇒ 如实 409 让调用方重试,
331
+ // 码与取消腿的同名情形共用(同一件事:并发决议赢了,它的恢复腿正在跑)。
332
+ const PREEMPT_RECLASSIFY_ATTEMPTS = 3;
333
+ for (let attempt = 0; !flagged; attempt++) {
318
334
  const now = await deps.runStore.getRun(taskId);
335
+ if (now !== undefined && isParkedRunStatus(now.status)) {
336
+ sendError(res, 409, "conflict.already_suspended", "task is already suspended", { taskId, status: now.status });
337
+ return;
338
+ }
339
+ if (now?.status === "running" && attempt < PREEMPT_RECLASSIFY_ATTEMPTS) {
340
+ flagged = await deps.runStore.requestPreempt(taskId, run.owner); // 行又活了 ⇒ 重挂 durable 旗(赢了就走下面的快路)
341
+ continue;
342
+ }
343
+ if (now?.status === "running") {
344
+ sendError(res, 409, "conflict.approval_settled", "pending approval was settled concurrently (decided or expired) — re-check the run and retry preempt if it is still active", { taskId, status: now.status });
345
+ return;
346
+ }
319
347
  sendJson(res, 202, { taskId, status: now?.status ?? "failed", note: "task no longer running — preempt is a no-op" });
320
348
  return;
321
349
  }
322
350
  preemptableRuns.get(taskId)?.abort(); // fast path: preempt landed on the running instance → suspend now
323
351
  sendJson(res, 202, { taskId, status: "preempting", note: "graceful durable yield — the task suspends at the next clean turn boundary if preempt-eligible; resume via POST /v1/assistant/tasks/:id/resume" });
324
352
  }
325
- else if (run.status === "suspended") {
326
- sendError(res, 409, "conflict.already_suspended", "task is already suspended", { taskId, status: "suspended" });
353
+ else if (isParkedRunStatus(run.status)) {
354
+ // 扫描P2 顺带件:park 是两个词(isParkedRunStatus 属主)——needs_review(plan_review/dry-run )
355
+ // 此前掉进 else 被谎报「already terminal — no-op」(无状态破坏,纯指路错)。extras.status 带真词。
356
+ sendError(res, 409, "conflict.already_suspended", "task is already suspended", { taskId, status: run.status });
327
357
  }
328
358
  else {
329
359
  sendJson(res, 202, { taskId, status: run.status, note: "task already terminal — preempt is a no-op" });
@@ -687,6 +717,30 @@ function redactPendingDisclosures(row) {
687
717
  return row;
688
718
  return { ...row, riskDescriptor: { ...rd, shadowedRule: redactSecrets(rd.shadowedRule) } };
689
719
  }
720
+ /**
721
+ * 🔴 [3683]-2 / [3684]②:两条 durable 读面(`GET /v1/approvals` + `/v1/approvals/stream`)的**唯一**
722
+ * 投影 —— 脱敏(#209 件4)+ 出身归因(#220 的另一半)一次做完。
723
+ *
724
+ * 出身格的病灶如实记:`governanceForced` 此前只在 409 体上算(`buildActiveRunConflict`),而运维队列
725
+ * 的行不算 —— 同一道 park 门,客户端从 409 看得见「这是运维治理层下的」,从队列看不见。它**不是**一个
726
+ * 落库缺口:判据的两半(行上 `riskDescriptor.shellGateDoctrine` + 本部署的治理姿态)在这条读面上都够得着,
727
+ * 缺的只是**没有人算**。所以补法 = 让两面共用 `governanceOriginOf` 这一个属主(在读面算的已知失真、
728
+ * 以及「根治要 core 在 mint 写进行里」的登记,逐字在 `runtime-governance.ts` 那个函数的顶注,不复述)。
729
+ *
730
+ * 🔴 `true` 才写键,恒不写 `false` —— 与活卡帧 / 409 体 / `card_json` 同一条纪律:缺席 = 「没有治理来源
731
+ * 的证据」,不是「这不是治理门」。
732
+ *
733
+ * ⚠️ **`delegation` 刻意不在这里**(同批如实登记,禁编造键):park 行上**没有**这条信息 —— core 的
734
+ * `Checkpoint` / `PendingAction` / `CheckpointGate` 三个面都不带 `{parentToolCallId, depth, agentName}`
735
+ * (`Checkpoint.state.delegationProvenance` 是 design/180 的内容安全聚合 `{version, coverageStartTurn,
736
+ * sawExternal, incomplete}`,与 ask 的出处链是两件事)。同步腿的这一格来自 `AskRequest.delegation`,
737
+ * 那条路 park 时不随行。⇒ 无供给,诚实缺席;要它得先有 core 侧的属主面。
738
+ */
739
+ function projectPendingForWire(row, governance) {
740
+ const redacted = redactPendingDisclosures(row);
741
+ const governanceForced = governanceOriginOf({ riskDescriptor: row.riskDescriptor ?? undefined }, governance);
742
+ return governanceForced ? { ...redacted, governanceForced } : redacted;
743
+ }
690
744
  /**
691
745
  * GET /v1/approvals/stream (design/80 native push): SSE — pushes pending-approval deltas so the portal
692
746
  * SUBSCRIBES ONCE instead of polling GET /v1/approvals every ~10s (better UX: near-real-time + no client poll
@@ -694,8 +748,12 @@ function redactPendingDisclosures(row) {
694
748
  * for a decided/expired/gone one. Cross-replica BY CONSTRUCTION — the poll reads the SHARED checkpoint table,
695
749
  * so a suspend on ANY replica is seen by an operator streaming on a DIFFERENT replica. A transient DB blip
696
750
  * heartbeats + retries (never kills the stream); a 15-min cap + req-close end it (parity with streamTaskTrace).
697
- * Poll-granularity caveat: a pending that resolves AND re-suspends on the same (session,toolCallId) within one
698
- * interval shows no delta acceptable (the live snapshot is always eventually correct; no decision is missed).
751
+ * Poll-granularity caveat (**narrowed** by [3684]②): the delta now re-emits a `pending` frame whenever a key's
752
+ * WIRE PROJECTION changes (upsert), not only when the key enters so a same-key re-suspend within one interval
753
+ * IS visible as long as anything on the row differs. What still shows no delta is the strictly degenerate case:
754
+ * a resolve + re-suspend on the same (session,toolCallId) whose projection is BYTE-IDENTICAL — indistinguishable
755
+ * by construction, and harmless (the card the operator sees is the card that is pending). Pins: the two halves of
756
+ * `test/wire-pairing-approvals-stream.test.ts` S3b (changed ⇒ re-emit with the NEW payload; unchanged ⇒ silence).
699
757
  *
700
758
  * A-002.19(2026-08-09 亲验定性=设计接受,非欠账):每连接自跑 pollMs 轮询打共享 checkpoint 表,
701
759
  * 之所以不建 fan-out/单轮询器基建——①消费方=portal 运营面,并发连接数量级为个位(不是用户面);
@@ -704,7 +762,10 @@ function redactPendingDisclosures(row) {
704
762
  * 数假设若被打破(portal 多开成常态),届时把 poll 收敛为进程内单轮询器多路复用——那是量级触发的
705
763
  * 演化,不是现在的缺陷。
706
764
  */
707
- export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_STREAM_POLL_MS) {
765
+ export async function streamApprovals(req, res, cs, scope,
766
+ /** [3684]②:出身归因的部署侧一半(缺席 ⇒ 归不出治理出身 ⇒ 键缺席,与 best-effort 同方向)。**位置在
767
+ * `pollMs` 之前**是刻意的:`pollMs` 只有测试传,而本参数是每个生产调用点都必须给的那一个。 */
768
+ governance, pollMs = APPROVALS_STREAM_POLL_MS) {
708
769
  // JSON-encode the (sessionId, toolCallId) pair so distinct pendings can NEVER collide into one Map key,
709
770
  // regardless of what a caller-supplied sessionId contains (any single-char delimiter — space OR even NUL — is
710
771
  // injectable by an adversarial id, masking/wrong-removing an approval card; adversarial finding). JSON escaping
@@ -720,13 +781,25 @@ export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_ST
720
781
  const start = Date.now();
721
782
  let lastBeat = Date.now();
722
783
  let prev = new Map();
784
+ /**
785
+ * 🔴 [3684]② / codex 对抗复审 [medium](验真后修):**上一拍每行的 wire 投影字节**。
786
+ *
787
+ * 旧 delta 只判「键进/键出」——对一条内容**不变**的 park 行那是对的,而 `governanceForced` 是**派生位**,
788
+ * 它随 `config.autonomy`(config-center 的 `apply-effective` 就地热改)在**同一条行还挂着**的时候翻面。
789
+ * 只判键的话,常驻订阅的 portal 会一直渲着旧徽标(最长到 15 分钟 cap 重连),而同一时刻
790
+ * `GET /v1/approvals` 已经给出另一个答案 —— 两条本该同源的读面当场分家。
791
+ *
792
+ * ⇒ 判据改成**投影字节变了就重发**。`pending` 帧对同一 (sessionId, toolCallId) 键因此是 **upsert**
793
+ * 语义(消费端本来就按这个键建表);重复发一帧是无害的(幂等覆盖),漏发一帧是错的徽标。
794
+ */
795
+ let prevFrames = new Map();
723
796
  let first = true;
724
797
  while (!closed) {
725
798
  let pending;
726
799
  try {
727
- // #209 件4:与 `GET /v1/approvals` **同一个**投影函数 —— 两条 durable 读面各洗各的就会漂
728
- // (SSE 那条恰恰是 operator 常驻订阅的那条,漏掉它等于没修)。
729
- pending = (await cs.listPending(scope)).map(redactPendingDisclosures);
800
+ // #209 件4 + [3684]②:与 `GET /v1/approvals` **同一个**投影函数(脱敏 + 出身归因)—— 两条 durable
801
+ // 读面各投各的就会漂(SSE 那条恰恰是 operator 常驻订阅的那条,漏掉它等于没修)。
802
+ pending = (await cs.listPending(scope)).map((r) => projectPendingForWire(r, governance));
730
803
  }
731
804
  catch {
732
805
  // a transient TiDB blip must NOT kill the subscription — heartbeat + retry next tick (fail-soft)
@@ -736,22 +809,26 @@ export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_ST
736
809
  continue;
737
810
  }
738
811
  const cur = new Map(pending.map((p) => [keyOf(p), p]));
812
+ // 一行只序列化一次:既是要发的帧体,也是与上一拍比对的判据(两处各算一遍必然漂)。
813
+ const curFrames = new Map([...cur].map(([k, p]) => [k, JSON.stringify({ type: "pending", ...p })]));
739
814
  if (first) {
740
815
  // initial snapshot so a freshly-subscribed portal renders the current queue immediately (no first-poll gap)
741
- for (const p of pending)
742
- res.write(`event: pending\ndata: ${JSON.stringify({ type: "pending", ...p })}\n\n`);
816
+ for (const frame of curFrames.values())
817
+ res.write(`event: pending\ndata: ${frame}\n\n`);
743
818
  res.write(`event: synced\ndata: ${JSON.stringify({ type: "synced", count: pending.length })}\n\n`);
744
819
  first = false;
745
820
  }
746
821
  else {
747
- for (const [k, p] of cur)
748
- if (!prev.has(k))
749
- res.write(`event: pending\ndata: ${JSON.stringify({ type: "pending", ...p })}\n\n`); // a new suspend
822
+ // 新 park **或**同一行的投影内容变了( `prevFrames` 顶注:派生位会在行不变时翻面)⇒ upsert 一帧
823
+ for (const [k, frame] of curFrames)
824
+ if (prevFrames.get(k) !== frame)
825
+ res.write(`event: pending\ndata: ${frame}\n\n`);
750
826
  for (const [k, p] of prev)
751
827
  if (!cur.has(k))
752
828
  res.write(`event: resolved\ndata: ${JSON.stringify({ type: "resolved", sessionId: p.sessionId, toolCallId: p.toolCallId })}\n\n`); // decided/expired/gone
753
829
  }
754
830
  prev = cur;
831
+ prevFrames = curFrames;
755
832
  if (Date.now() - start > SSE_MAX_STREAM_MS) {
756
833
  res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "approvals stream reached its 15-minute cap — reconnect to continue" })}\n\n`);
757
834
  break;
@@ -1,4 +1,5 @@
1
- import { DEFAULT_EFFORT_LEVELS } from "@sema-agent/core";
1
+ import { DEFAULT_EFFORT_LEVELS, expandTiers } from "@sema-agent/core";
2
+ import { isModelAllowlisted } from "../../model-select.js";
2
3
  import { cwdHonored } from "../../task-cwd.js";
3
4
  import { mcpInjectionHonored } from "../../task-mcp.js";
4
5
  import { sendJson, sendError } from "../send.js";
@@ -299,6 +300,18 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
299
300
  backend: deps.backend,
300
301
  parkFacility: deps.checkpointStore !== undefined,
301
302
  }).active,
303
+ // #229(设计稿 233 稿B v2 §3):回决**理由**位在本二进制里存在的探测位 —— live
304
+ // `POST /v1/tool-approvals/:id/respond` 收 `note` + 回执带 `noteRecorded`,durable 回决口的三个回体
305
+ // 投 `decisionNote`。
306
+ // 🔴 **恒 true,且必须与 `noteRecorded` 同车**:老 server 对未知请求键**静默忽略 + 照回 200**
307
+ // (live 腿的 parser 是非 strict 的手写形),所以没有这一位时,「理由记上了」与「这台压根不认识
308
+ // 这个键」在 wire 上不可判别 —— 壳只能拿一个 200 猜。位在场 ⇒ 按 `noteRecorded` 逐次读结果;
309
+ // 位缺席 ⇒ 老 server,别渲这一格。
310
+ // 🔴 谓词**刻意不挂任何设施**(不是 `Boolean(deps.backend)` 之类):本位声明的是「这个二进制认识
311
+ // 这个键」这件版本事实,**不是**「你的理由一定会被记下」——后者是 per-call 的,由 `noteRecorded`
312
+ // 逐次如实回答(店缺席/店抖动时它就是 `false`)。把部署条件混进版本位,会让「says yes ⟺ 面真能用」
313
+ // 这句话在两个不同的问题上各说一半(与 `permissionRulesRevoke` 的存在性信号同族,见其注)。
314
+ approvalDecisionNote: true,
302
315
  // [1469] POST /v1/side-query(core 1.361 Runner.sideQuery 包装):一次性 brain 路由问答,无 session
303
316
  // 副作用。恒可用(runner 自带)——探测位供壳判「引擎腿在」而非 trial-by-404。
304
317
  sideQuery: true,
@@ -382,6 +395,11 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
382
395
  // today = env-derived config.models; later the sema registry (sema-registry). The internal
383
396
  // `default` alias is hidden (it's a fallback ref, not a user-pickable model).
384
397
  if (req.method === "GET" && url === "/v1/models") {
398
+ // #233 / A-002.8 读面:名单**在场时**每行补 `atMentionable`(名单缺席/空 ⇒ 整个键不发 —— 缺省形的
399
+ // 字节逐字不变,消费端"没有这个键"= 这台没开名单治理,而不是"这行不能点")。判定与三个执法点同源
400
+ // (isModelAllowlisted + 增广目录):壳面板上灰掉的行与服务端真会拒的行永远是同一批。
401
+ const atCatalog = expandTiers(deps.config.models, deps.config.tiers ?? {}) ?? deps.config.models;
402
+ const atAllowlist = deps.config.atModelAllowlist ?? [];
385
403
  const models = Object.entries(deps.config.models)
386
404
  .filter(([name]) => name !== "default")
387
405
  .map(([name, m]) => ({
@@ -402,6 +420,7 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
402
420
  // model (effort doesn't apply). The accept-set on a request is broader (any core ThinkingLevel) — this is
403
421
  // the advertised picker default, not a hard allow-list.
404
422
  ...(m.reasoning ? { supportedEffortLevels: [...DEFAULT_EFFORT_LEVELS] } : {}),
423
+ ...(atAllowlist.length > 0 ? { atMentionable: isModelAllowlisted(name, atCatalog, atAllowlist) } : {}),
405
424
  }));
406
425
  // [865]③ id/name 撕裂消解:`default` 历史上是 **id 形**(壳面板行 value 却是 name 形,壳侧只能打补丁
407
426
  // 拼回)。additive 双给:`default` 保持 id 形字节不动(既有消费者),新 `defaultModel = { id, name }`
@@ -80,5 +80,23 @@ export declare function buildStaticWiringAudit(facts: StaticWiringFacts): Static
80
80
  export declare function assertStaticWiringConsistent(facts: StaticWiringFacts, logger: {
81
81
  warn: (msg: string, meta?: Record<string, unknown>) => void;
82
82
  } | undefined): StaticWiringAudit;
83
+ /**
84
+ * [3399]③ clay 裁 (b)(2026-08-12,core [3648]④ + cli [3649]① 双背书):**零凭证 turnkey 形**的
85
+ * loopback 来源门判据(单一属主,端点与测试同吃)。
86
+ *
87
+ * 只在「operator 名单空 ∧ 非多租户 ∧ 全局 service-credential 门也不在场(没配 SERVICE_AUTH_TOKEN /
88
+ * AUTH_TOKENS)」的部署形上判——那正是全局门整个不设、诊断端点此前无认证可读的形。诊断面泄的是装配
89
+ * 拓扑(store 接线/能力位/治理姿态),对误暴露公网端口的 turnkey 机器构成侦察面。(b) 对本机自用零摩擦
90
+ * (壳缺省钉 BIND_HOST=127.0.0.1,local curl 照常),对远程侦察 fail-closed;配了 token 的部署远程读
91
+ * 走认证(全局门),不进本臂。
92
+ *
93
+ * 判源=TCP 对端地址(`req.socket.remoteAddress`),**非任何头**——XFF 类头在无反代的 turnkey 形上本就
94
+ * 是伪造面;有反代的部署必然配 token(反代后 remoteAddress 恒 loopback,门自然放行,语义仍对:该形的
95
+ * 边界由反代+token 承担)。`remoteAddress` 缺席(socket 已断等边缘)⇒ 判拒(fail-closed,安全轴方向)。
96
+ */
97
+ export declare function zeroCredentialLoopbackDenied(remoteAddress: string | undefined, config: {
98
+ authToken?: string;
99
+ authTokens?: Record<string, string>;
100
+ }): boolean;
83
101
  export declare function handleDiagnostics(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
84
102
  //# sourceMappingURL=diagnostics.d.ts.map
@@ -91,6 +91,28 @@ export function assertStaticWiringConsistent(facts, logger) {
91
91
  }
92
92
  return audit;
93
93
  }
94
+ /**
95
+ * [3399]③ clay 裁 (b)(2026-08-12,core [3648]④ + cli [3649]① 双背书):**零凭证 turnkey 形**的
96
+ * loopback 来源门判据(单一属主,端点与测试同吃)。
97
+ *
98
+ * 只在「operator 名单空 ∧ 非多租户 ∧ 全局 service-credential 门也不在场(没配 SERVICE_AUTH_TOKEN /
99
+ * AUTH_TOKENS)」的部署形上判——那正是全局门整个不设、诊断端点此前无认证可读的形。诊断面泄的是装配
100
+ * 拓扑(store 接线/能力位/治理姿态),对误暴露公网端口的 turnkey 机器构成侦察面。(b) 对本机自用零摩擦
101
+ * (壳缺省钉 BIND_HOST=127.0.0.1,local curl 照常),对远程侦察 fail-closed;配了 token 的部署远程读
102
+ * 走认证(全局门),不进本臂。
103
+ *
104
+ * 判源=TCP 对端地址(`req.socket.remoteAddress`),**非任何头**——XFF 类头在无反代的 turnkey 形上本就
105
+ * 是伪造面;有反代的部署必然配 token(反代后 remoteAddress 恒 loopback,门自然放行,语义仍对:该形的
106
+ * 边界由反代+token 承担)。`remoteAddress` 缺席(socket 已断等边缘)⇒ 判拒(fail-closed,安全轴方向)。
107
+ */
108
+ export function zeroCredentialLoopbackDenied(remoteAddress, config) {
109
+ const anyServiceAuth = Boolean(config.authToken) || Object.keys(config.authTokens ?? {}).length > 0;
110
+ if (anyServiceAuth)
111
+ return false; // 凭证在场 ⇒ 边界归全局 service-credential 门,本判不参与
112
+ const addr = remoteAddress ?? "";
113
+ const isLoopback = addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
114
+ return !isLoopback;
115
+ }
94
116
  export async function handleDiagnostics(req, res, url, ctx) {
95
117
  const miss = { fell: false };
96
118
  await handleDiagnosticsBody(req, res, url, ctx, miss);
@@ -122,6 +144,10 @@ async function handleDiagnosticsBody(req, res, url, ctx, miss) {
122
144
  return;
123
145
  }
124
146
  }
147
+ else if (zeroCredentialLoopbackDenied(req.socket.remoteAddress, deps.config)) {
148
+ sendError(res, 403, "auth.loopback_only", "wiring diagnostics on a zero-credential deployment are readable from loopback only — configure SERVICE_AUTH_TOKEN to read remotely");
149
+ return;
150
+ }
125
151
  // 静态半场是 **composition root 在 boot 算的**(与拒启自检同一份产物,不可能与它不同)。缺席 ⇒ 这个
126
152
  // 进程不是由 main.ts 装起来的(测试夹具形),此面诚实地不存在 —— 落到全局 404,而不是回一个空壳 200。
127
153
  if (!deps.staticWiring) {