@sema-agent/server 7.64.0 → 7.66.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 (40) hide show
  1. package/USAGE.md +18 -5
  2. package/dist/boot/governance-seams.js +1 -1
  3. package/dist/boot/resolve-spec.d.ts +0 -5
  4. package/dist/boot/resolve-spec.js +3 -4
  5. package/dist/boot/stores.js +3 -3
  6. package/dist/capabilities/hands-lane.d.ts +55 -13
  7. package/dist/capabilities/hands-lane.js +22 -11
  8. package/dist/config-catalog.js +1 -1
  9. package/dist/config-center/read-face.js +10 -8
  10. package/dist/config-center/skills-mcp.d.ts +3 -3
  11. package/dist/config-center/skills-mcp.js +17 -4
  12. package/dist/config-center/types.d.ts +11 -0
  13. package/dist/config-provider.js +1 -0
  14. package/dist/config-types.d.ts +24 -5
  15. package/dist/config.js +7 -4
  16. package/dist/http/routes/approvals-assistant.js +18 -0
  17. package/dist/http/routes/diagnostics.d.ts +0 -11
  18. package/dist/http/routes/diagnostics.js +2 -0
  19. package/dist/http/routes/tasks.js +6 -1
  20. package/dist/http/server.js +1 -1
  21. package/dist/main.js +2 -4
  22. package/dist/mcp-tool-faces.d.ts +64 -0
  23. package/dist/mcp-tool-faces.js +93 -0
  24. package/dist/observability/fail-open.d.ts +4 -0
  25. package/dist/observability/fail-open.js +4 -0
  26. package/dist/plugins/checkpoint-store-sql.d.ts +68 -7
  27. package/dist/plugins/checkpoint-store-sql.js +90 -25
  28. package/dist/plugins/local-checkpoint-store.d.ts +19 -1
  29. package/dist/plugins/local-checkpoint-store.js +6 -1
  30. package/dist/plugins/pg-pool.js +5 -0
  31. package/dist/plugins/tidb-pool.js +11 -0
  32. package/dist/read-face-posture.d.ts +52 -0
  33. package/dist/read-face-posture.js +20 -0
  34. package/dist/task-mcp.d.ts +18 -4
  35. package/dist/task-mcp.js +23 -4
  36. package/dist/tool-approval.js +2 -2
  37. package/dist/trace/core-keyset-guard.d.ts +2 -2
  38. package/dist/trace/project.d.ts +21 -0
  39. package/dist/trace/project.js +21 -1
  40. package/package.json +4 -4
@@ -1,14 +1,3 @@
1
- /**
2
- * [ref] 件二+件三 —— **装配自证**域:启动期静态自检(拒启判据)与 operator 诊断读面
3
- * (`GET /v1/diagnostics/wiring`)。
4
- *
5
- * 🔴 两件为什么同住一个模块:它们回答的是同一个问题(「这台 worker 到底接了什么线」),只是一个在 boot
6
- * 用它拒绝启动、一个在运行期把它读给 operator。分成两处 = 拒启用的判据与诊断报的读数会各自演化,而
7
- * 「拒启说没矛盾、诊断页显示矛盾」正是本设计要消灭的那类装配谎言。故 boot 半场是本文件导出的**纯函数**
8
- * (可单测、无 I/O),路由半场只是它的读口。
9
- *
10
- * 分层:本模块不值 import `server.ts`(那条边会闭合运行时装载环),对它只有 `import type`。
11
- */
12
1
  import type { IncomingMessage, ServerResponse } from "node:http";
13
2
  import type { WiringManifest } from "@sema-agent/core";
14
3
  import { type StreamApprovalGate, type StreamApprovalGateInput } from "../../tool-approval.js";
@@ -1,3 +1,4 @@
1
+ import { buildReadFaceWiring } from "../../read-face-posture.js";
1
2
  import { resolveStreamApprovalGate } from "../../tool-approval.js";
2
3
  import { sendJson, sendError } from "../send.js";
3
4
  import { gatedPrincipal, explicitOperatorOk, isLoopbackAddress, verifiedIdentitySource } from "../principal-gate.js";
@@ -90,6 +91,7 @@ async function handleDiagnosticsBody(req, res, url, ctx, miss) {
90
91
  memoryPosture: deps.memoryPosture ?? null,
91
92
  sqlEngine: deps.sqlEngineFacts?.() ?? null,
92
93
  writeProtection: deps.writeProtection ?? null,
94
+ readFace: buildReadFaceWiring(deps.config),
93
95
  configApply: deps.configApplyState?.() ?? null,
94
96
  mcpRevocations: deps.mcpRevocationState?.() ?? null,
95
97
  serverGates: buildServerWiringGates({
@@ -12,7 +12,7 @@ import { clearTurnActivity, readTurnActivityMs, recordTurnActivity } from "../..
12
12
  import { buildCancelContext } from "../../run-cancel-context.js";
13
13
  import { recordResourceWindow } from "../../resource-window.js";
14
14
  import { redactSecrets } from "../../trace/redact.js";
15
- import { contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, wiringManifestOperatorEventData, humanInputEventData, appendModelUsageDelta, attachModelUsage } from "../../trace/project.js";
15
+ import { contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, wiringManifestOperatorEventData, toolRosterDeltaEventData, humanInputEventData, appendModelUsageDelta, attachModelUsage } from "../../trace/project.js";
16
16
  import { cascadeConfig, runMeta } from "../run-meta.js";
17
17
  import { scopedIdempotencyKey } from "../idempotency.js";
18
18
  import { sendJson, sendError, sseHeaders } from "../send.js";
@@ -484,6 +484,11 @@ async function handleTasksBody(req, res, url, ctx, miss) {
484
484
  : wiringManifestEventData(ev)),
485
485
  });
486
486
  }
487
+ else if (ev.type === "tool_roster_delta") {
488
+ const rosterDelta = toolRosterDeltaEventData(ev);
489
+ if (rosterDelta !== undefined)
490
+ sseData(res, { type: "tool_roster_delta", ...rosterDelta });
491
+ }
487
492
  else if (ev.type === "human_input") {
488
493
  sseData(res, { type: "human_input", ...humanInputEventData(ev) });
489
494
  }
@@ -125,7 +125,7 @@ function mentionableNames(config, catalog) {
125
125
  }
126
126
  function isCheckpointReopenedFailure(r) {
127
127
  const code = failureCodeOf(r.terminal);
128
- return code === "resume.env_failed" || code === "resume.tool_unavailable";
128
+ return code === "resume.env_failed" || code === "resume.tool_unavailable" || code === "resume.tool_contract_mismatch";
129
129
  }
130
130
  function nestedParkStatusOf(r) {
131
131
  const paused = nestedPauseOf(r.terminal);
package/dist/main.js CHANGED
@@ -20,7 +20,7 @@ import { assertGateIntentServiceable, buildForceDurableGateInertNotice, hasOpera
20
20
  import { loadSkills } from "./capabilities/skills.js";
21
21
  import { createRepoClient } from "./capabilities/repo-tools.js";
22
22
  import { buildScenarios, builtinScenarioDetails } from "./capabilities/scenarios.js";
23
- import { createHandsLaneRegistry, pickHandsRunner, withoutExecutionEnv } from "./capabilities/hands-lane.js";
23
+ import { createHandsRunnerSelector, withoutExecutionEnv } from "./capabilities/hands-lane.js";
24
24
  import { createLogger } from "./observability/logger.js";
25
25
  import { createToolTracer, createPermissionDeniedMeter } from "./observability/tool-trace.js";
26
26
  import { createRegistryJwtVerifier } from "./auth-bridge.js";
@@ -730,9 +730,7 @@ async function main() {
730
730
  : undefined,
731
731
  sessionEventsMaxConnections: sessionWatchRegistry ? posIntEnv(process.env.SESSION_EVENTS_MAX_CONNS, 256, 100_000) : undefined,
732
732
  };
733
- const handsLanes = createHandsLaneRegistry();
734
733
  const resolveSpec = createResolveSpec({
735
- handsLanes,
736
734
  config, logger, metrics, localRoot, scenarios, principalCaps, centerRuntimeCapsResolver,
737
735
  getCenterPrompts: () => configCenter.getCenterPrompts(),
738
736
  getKeyResolver: () => configCenter.getKeyResolver(),
@@ -746,7 +744,7 @@ async function main() {
746
744
  liveQuestionFace: question,
747
745
  approverSeat: createRunnerDepsOnAsk(toolApproval),
748
746
  });
749
- const runnerFor = (spec) => pickHandsRunner(handsLanes.laneOf(spec), { full: runner, handsless: handslessRunner });
747
+ const runnerFor = createHandsRunnerSelector({ full: runner, handsless: handslessRunner });
750
748
  const sideQuery = createSideQueryLane({ brain, config, getKeyResolver: () => configCenter.getKeyResolver() });
751
749
  const server = createHttpServer({ runner, runnerFor, sideQuery, config, resolveSpec, stores, coordinators, seams, observability, governance, deployment, knobs });
752
750
  runDenySweep = server.denyExpiredApprovals;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `McpServerSpec.toolFaces` —— MCP 工具的**面覆盖层**(core 7.8.0 [ref] §1.1/§2.7;声明面
3
+ * settings-schema 1.9.0)。一条规则、三条腿共用(请求腿 `task-mcp.ts normalizeServer` / center 腿
4
+ * `config-center/skills-mcp.ts resolveMcpServers` / 本地 config.d 腿 `config-provider.ts` 只做承载),
5
+ * 与 {@link import("./mcp-source-label.js").readMcpSourceLabel} 同一个「判据单点、三腿一致」的形。
6
+ *
7
+ * ## 分工(为什么本层**不**复述 core 的那张表)
8
+ * 覆盖层的**语义**校验属 core 的摄入侧,而且只有它做得了:`pathTarget.param` 是不是这只工具**物化后
9
+ * schema** 的顶层属性、审批卡要的键在不在、`ruleFace` 指的是不是标量键 —— 三件都要看远端 server 真发
10
+ * 回来的 schema,本层手里根本没有。core 的处置也已经定死:不合 ⇒ **这只工具在这条腿上不挂**,并通告
11
+ * 一次 `config.tool_face_invalid`(亲读 `dist/core/runner/tool-face-overlay.js` 的 `return undefined` 臂 ——
12
+ * 该文件 d.ts 头注里那句「the tool mounts BARE」是**过期**的,以实现为准)。
13
+ *
14
+ * 本层因此只做 core 的校验器**假定成立**的那一件事:**JSON 结构合不合 TS 型**。理由是硬的 —— core 的
15
+ * `toolFaceProblem` 是按 TS 型写的,`renderHints.summaryParams` 是个字符串时它会 `.find is not a function`
16
+ * 地把 prepare 整条炸掉,`ruleFace.params` 是个数字时 `for...of` 直接抛。原样透传一份任意 JSON 不是
17
+ * 「解析透明」,是把类型错误变成运行期崩溃。
18
+ *
19
+ * 闭词(`family` / `pathTarget.access` / `renderHints.approvalCard`)判据**取自 core 的导出常量**
20
+ * (`TOOL_FAMILIES` / `TOOL_PATH_ACCESSES` / `TOOL_APPROVAL_CARDS`),本仓一个词都不枚举 —— 与
21
+ * `MCP_DELIVERY_VERDICTS` 的消费姿势同款:词表单一属主在 core,抄一遍就是抄了一张会漂的表。
22
+ *
23
+ * ## 坏形的方向:**整条 server 不进**(fail-closed)
24
+ * 🔴 这一条被 codex 对抗复审 r1 的 [high] **翻正过一次**,把翻正的理由留在这里,因为它是本模块最容易
25
+ * 想错的一格:初版按 `contentOrigin` 的先例判「丢声明、留 server + 留痕」,而那条先例管的是**放宽**键
26
+ * (丢它 ⇒ 回落到过度标记 = fail-closed 的那一侧)。`toolFaces` 是**收窄**声明:它声明的 `pathTarget`
27
+ * 正是敏感路径策略、写围栏、编辑跟踪读的那一格 —— 丢掉它,那只工具就**无面挂载**,运维声明的保护
28
+ * 静默失效。codex 实测(同一只 MCP 写工具、同一个 `/work/.env` 目标,仅把 `renderHints.summaryParams`
29
+ * 从数组写成字符串):`createSensitivePathPolicy` 从 deny 变成 allow。一条 warn 补偿不了这个方向。
30
+ *
31
+ * 所以判据回到本仓对**收窄键**已经成文的那一条(`task-mcp.ts` 的 `allowTools`,[ref] / [ref] codex F1):
32
+ * **一个键在场就必须合法,否则这条 server 整条不进**。三问逐条:
33
+ * ① 谁需要 —— 在部署侧给 MCP 工具声明写围栏 / 审批卡 / 显示名的运维;
34
+ * ② 谁受伤 —— 覆盖层写错的那台 server:它**整条**不挂(请求腿进 `dropped` 并点名上报,center 腿跳过
35
+ * 该条、兄弟 server 照挂)。代价是真的(少一套工具),但它**响亮且立刻**;反方向的代价是一条
36
+ * **静默失效的安全声明**,而后者按 [ref] 不许存在于安全轴上;
37
+ * ③ 拿什么补偿 —— 报出来的名字本身:请求腿 `mcp_injection_dropped{dropped:[name]}`、center 腿
38
+ * `sema_registry_mcp_tool_faces_invalid{server,problem,note}` 一条点名 warn(`problem` 里带工具名与哪一段不合;运维照着改那一行即可)。
39
+ * 每只工具的面是**整只**判(core 明写 "applied whole or not at all");而**任一只**判不出形 ⇒ 整条 server
40
+ * 不进 —— 「留下好的那几只」同样是让坏的那一只无面挂载,与整张丢没有区别。
41
+ */
42
+ import { type McpServerSpec } from "@sema-agent/core";
43
+ type ToolFaces = NonNullable<McpServerSpec["toolFaces"]>;
44
+ /**
45
+ * 一条 server 条目的 `toolFaces` 判形结果:
46
+ * · `{ ok: true, faces }` —— 合形(`faces` 缺席 = 这台 server 没有覆盖层,或声明了一个**空**覆盖层:
47
+ * 两者对 core 同义,都不铸键);
48
+ * · `{ ok: false, problem }` —— 判不出形,**整条 server 不进**(方向与理由见模块头注),`problem` 是
49
+ * 给运维的一句点名(只带工具名与哪一段不合,不带面的值)。
50
+ */
51
+ export type McpToolFacesRead = {
52
+ ok: true;
53
+ faces?: ToolFaces;
54
+ } | {
55
+ ok: false;
56
+ problem: string;
57
+ };
58
+ /**
59
+ * 从任一腿的原始条目里读出 `toolFaces` —— **三条腿共用的唯一判据点**。
60
+ * 缺席 ⇒ 合形无覆盖层;整只不是对象 / **任一只**工具的面判不出形 ⇒ `ok:false`(调用方整条 server 不收)。
61
+ */
62
+ export declare function readMcpToolFaces(v: unknown): McpToolFacesRead;
63
+ export {};
64
+ //# sourceMappingURL=mcp-tool-faces.d.ts.map
@@ -0,0 +1,93 @@
1
+ import { TOOL_APPROVAL_CARDS, TOOL_FAMILIES, TOOL_PATH_ACCESSES } from "@sema-agent/core";
2
+ function isRecord(v) {
3
+ return typeof v === "object" && v !== null && !Array.isArray(v);
4
+ }
5
+ function isStringArray(v) {
6
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
7
+ }
8
+ function memberOf(table, v) {
9
+ return typeof v === "string" && table.includes(v);
10
+ }
11
+ function onlyKeys(rec, allowed) {
12
+ return Object.keys(rec).every((k) => allowed.includes(k));
13
+ }
14
+ function readOneFace(raw) {
15
+ if (!isRecord(raw))
16
+ return undefined;
17
+ if (!onlyKeys(raw, ["family", "pathTarget", "renderHints", "ruleFace"]))
18
+ return undefined;
19
+ const out = {};
20
+ if (raw.family !== undefined) {
21
+ if (!memberOf(TOOL_FAMILIES, raw.family))
22
+ return undefined;
23
+ out.family = raw.family;
24
+ }
25
+ if (raw.pathTarget !== undefined) {
26
+ const pt = raw.pathTarget;
27
+ if (!isRecord(pt) || typeof pt.param !== "string" || !memberOf(TOOL_PATH_ACCESSES, pt.access))
28
+ return undefined;
29
+ if (!onlyKeys(pt, ["param", "access", "aliases", "skillScopeEligible"]))
30
+ return undefined;
31
+ if (pt.aliases !== undefined && !isStringArray(pt.aliases))
32
+ return undefined;
33
+ if (pt.skillScopeEligible !== undefined && typeof pt.skillScopeEligible !== "boolean")
34
+ return undefined;
35
+ out.pathTarget = {
36
+ param: pt.param,
37
+ access: pt.access,
38
+ ...(pt.aliases !== undefined ? { aliases: pt.aliases } : {}),
39
+ ...(pt.skillScopeEligible === true ? { skillScopeEligible: true } : {}),
40
+ };
41
+ }
42
+ if (raw.renderHints !== undefined) {
43
+ const rh = raw.renderHints;
44
+ if (!isRecord(rh) || !onlyKeys(rh, ["userFacingName", "activity", "summaryParams", "resultCards", "approvalCard"]))
45
+ return undefined;
46
+ if (rh.userFacingName !== undefined && typeof rh.userFacingName !== "string")
47
+ return undefined;
48
+ if (rh.activity !== undefined && typeof rh.activity !== "string")
49
+ return undefined;
50
+ if (rh.summaryParams !== undefined && !isStringArray(rh.summaryParams))
51
+ return undefined;
52
+ if (rh.resultCards !== undefined && !isStringArray(rh.resultCards))
53
+ return undefined;
54
+ if (rh.approvalCard !== undefined && !memberOf(TOOL_APPROVAL_CARDS, rh.approvalCard))
55
+ return undefined;
56
+ out.renderHints = {
57
+ ...(rh.userFacingName !== undefined ? { userFacingName: rh.userFacingName } : {}),
58
+ ...(rh.activity !== undefined ? { activity: rh.activity } : {}),
59
+ ...(rh.summaryParams !== undefined ? { summaryParams: rh.summaryParams } : {}),
60
+ ...(rh.resultCards !== undefined ? { resultCards: rh.resultCards } : {}),
61
+ ...(rh.approvalCard !== undefined ? { approvalCard: rh.approvalCard } : {}),
62
+ };
63
+ }
64
+ if (raw.ruleFace !== undefined) {
65
+ const rf = raw.ruleFace;
66
+ if (!isRecord(rf) || !onlyKeys(rf, ["primaryParams", "params"]))
67
+ return undefined;
68
+ if (rf.primaryParams !== undefined && !isStringArray(rf.primaryParams))
69
+ return undefined;
70
+ if (rf.params !== undefined && !isStringArray(rf.params))
71
+ return undefined;
72
+ out.ruleFace = {
73
+ ...(rf.primaryParams !== undefined ? { primaryParams: rf.primaryParams } : {}),
74
+ ...(rf.params !== undefined ? { params: rf.params } : {}),
75
+ };
76
+ }
77
+ return out;
78
+ }
79
+ export function readMcpToolFaces(v) {
80
+ if (v === undefined)
81
+ return { ok: true };
82
+ if (!isRecord(v))
83
+ return { ok: false, problem: `toolFaces is ${Array.isArray(v) ? "an array" : typeof v}, not an object of per-tool faces` };
84
+ const faces = {};
85
+ for (const [tool, raw] of Object.entries(v)) {
86
+ const face = readOneFace(raw);
87
+ if (face === undefined)
88
+ return { ok: false, problem: `the declared face for tool ${JSON.stringify(tool)} does not fit the tool-face shape` };
89
+ faces[tool] = face;
90
+ }
91
+ return Object.keys(faces).length > 0 ? { ok: true, faces } : { ok: true };
92
+ }
93
+ //# sourceMappingURL=mcp-tool-faces.js.map
@@ -17,6 +17,10 @@ export declare const FAIL_OPEN_TAGS: {
17
17
  readonly cls: "F";
18
18
  readonly note: "S-136(core 7.6.0 design/390 S6-A,`trace/project.ts` 的 `screenedGate`,写腿与两条读腿共用):一条 `tool_end` / 账本行携带的 `GateOutcome` 过不了 core 的 `screenGateOutcome`(I1–I4 不变量,或某一格词出闭集)⇒ **整条 gate 记录不上帧**(report + withhold,core 契约逐字给出的两种响亮形之一;绝不「静默修补」成一条自洽的记录)。放行的最坏后果=这一帧的消费端渲染不出「谁拒的 / 那次等待怎么结束的」,退回到 `isError` + 文案;它**不改变任何裁决**(门早已在 core 里判完,这里只是观测面)。反方向(把一条自相矛盾的记录照发)更坏:消费端按 I2/I3 读出来的结论会是假的。计数让「某个世代的产生者一直在发坏记录」显形。";
19
19
  };
20
+ readonly "server.trace.tool-roster-malformed": {
21
+ readonly cls: "F";
22
+ readonly note: "⑪(core 7.8.0 design/388,`trace/project.ts` 的 `wiring_manifest.tools` 段与 `tool_roster_delta` 构造器共用):载荷过不了 core 自己的 typebox 判形器(`ToolRoster` / `ToolRosterDelta`)⇒ 那一段缺席 / 那一帧不发,**绝不半张脸上 wire**。放行的最坏后果=消费端这条腿看不到工具名册(壳退回按名字分组、用通用审批卡),不影响任何裁决——名册是观测/渲染面,门在引擎里早已判完。反方向(把一份判不了形的名册照发)更坏:消费端会把读不出的行当成「这只工具没挂」。计数让「某个世代的产生者一直在发坏名册」显形。";
23
+ };
20
24
  readonly "server.task-mcp.source-label-dropped": {
21
25
  readonly cls: "F";
22
26
  readonly note: "B-036 / S-143(`mcp-source-label.ts readMcpSourceLabel`,请求腿 + center 腿共用):`mcpServers[].source`(分组标签,显示用、开集)坏形——空串 / 超 80 字 / 非字符串——**丢键留 server**。放行的最坏后果=这台 server 在 wiring_manifest.mcp[] 里没有分组标签(难看),不是门/权限/凭证;本地 config.d 腿由 settings-schema 1.7.0 在解析处拒,故本臂只在请求腿与远端 /effective 直读 JSON 腿可达。";
@@ -8,6 +8,10 @@ export const FAIL_OPEN_TAGS = {
8
8
  cls: "F",
9
9
  note: "S-136(core 7.6.0 design/390 S6-A,`trace/project.ts` 的 `screenedGate`,写腿与两条读腿共用):一条 `tool_end` / 账本行携带的 `GateOutcome` 过不了 core 的 `screenGateOutcome`(I1–I4 不变量,或某一格词出闭集)⇒ **整条 gate 记录不上帧**(report + withhold,core 契约逐字给出的两种响亮形之一;绝不「静默修补」成一条自洽的记录)。放行的最坏后果=这一帧的消费端渲染不出「谁拒的 / 那次等待怎么结束的」,退回到 `isError` + 文案;它**不改变任何裁决**(门早已在 core 里判完,这里只是观测面)。反方向(把一条自相矛盾的记录照发)更坏:消费端按 I2/I3 读出来的结论会是假的。计数让「某个世代的产生者一直在发坏记录」显形。",
10
10
  },
11
+ "server.trace.tool-roster-malformed": {
12
+ cls: "F",
13
+ note: "⑪(core 7.8.0 design/388,`trace/project.ts` 的 `wiring_manifest.tools` 段与 `tool_roster_delta` 构造器共用):载荷过不了 core 自己的 typebox 判形器(`ToolRoster` / `ToolRosterDelta`)⇒ 那一段缺席 / 那一帧不发,**绝不半张脸上 wire**。放行的最坏后果=消费端这条腿看不到工具名册(壳退回按名字分组、用通用审批卡),不影响任何裁决——名册是观测/渲染面,门在引擎里早已判完。反方向(把一份判不了形的名册照发)更坏:消费端会把读不出的行当成「这只工具没挂」。计数让「某个世代的产生者一直在发坏名册」显形。",
14
+ },
11
15
  "server.task-mcp.source-label-dropped": {
12
16
  cls: "F",
13
17
  note: "B-036 / S-143(`mcp-source-label.ts readMcpSourceLabel`,请求腿 + center 腿共用):`mcpServers[].source`(分组标签,显示用、开集)坏形——空串 / 超 80 字 / 非字符串——**丢键留 server**。放行的最坏后果=这台 server 在 wiring_manifest.mcp[] 里没有分组标签(难看),不是门/权限/凭证;本地 config.d 腿由 settings-schema 1.7.0 在解析处拒,故本臂只在请求腿与远端 /effective 直读 JSON 腿可达。",
@@ -1,6 +1,6 @@
1
1
  import type { Pool as MySqlPool } from "mysql2/promise";
2
2
  import type { Pool as PgPool } from "pg";
3
- import { type Checkpoint, type CheckpointGate, type CheckpointState, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ResumeOutcome, type ResolvedOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor, type StoreDurability, type StoreFidelity } from "@sema-agent/core";
3
+ import { type ExecutionOutcomeRecordWord, type GateOutcome, type Checkpoint, type CheckpointGate, type CheckpointState, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ResumeOutcome, type ResolvedOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor, type StoreDurability, type StoreFidelity } from "@sema-agent/core";
4
4
  import { type RuleOfferProjection } from "../approval-card.js";
5
5
  import { type SqlDialect, type SqlDriver } from "./sql-driver.js";
6
6
  export { approvalPayloadFingerprint } from "./approval-payload-fingerprint.js";
@@ -195,16 +195,31 @@ export declare function pendingRowPrecedes(a: {
195
195
  createdAt: number;
196
196
  token: string;
197
197
  }, pick?: "newest" | "oldest"): boolean;
198
+ /**
199
+ * `checkpoint` 表上**本仓后加、且被无条件引用**的列册 —— 升级前置探针({@link assertCheckpointColumnSchema})
200
+ * 的唯一数据源。一行一列:列名 + 这一列不在时**运行期**先坏在哪(拒启文案逐字用它,所以运维读到的是
201
+ * 「我会先在哪里挂」,不是一句泛泛的 schema 不齐)。
202
+ *
203
+ * 🔴 为什么是一张表而不是每列一个 `assert*ColumnSchema` 函数(S-160 归一):[ref]① 那次是一列一函数,
204
+ * 本批 [ref] 又要加两列 —— 照抄就是第二、第三份三十行的同形代码,而它们**唯一的差别只有列名与那句话**。
205
+ * 探测法(恒空 `WHERE 1=0` 读 + 方言缺列码)、失败方向(只在**可证**缺列时才给删表指路)、恢复动作
206
+ * (DROP TABLE checkpoint 的代价交代)三者对整张表是同一条,所以规则只留一条,列变成数据:下一次加列
207
+ * 只加一行,不再多一条规则。
208
+ */
209
+ export declare const CHECKPOINT_ADDED_COLUMNS: ReadonlyArray<{
210
+ column: string;
211
+ breaksAt: string;
212
+ }>;
198
213
  /**
199
214
  * [ref]① 升级前置断言 —— **拒启**,不是 warn(形照 [ref] `assertApprovalAskRuleMaterialSchema` / [ref] /
200
215
  * [ref] 三条先例;codex 店面轮 R1-[high] 采纳 —— 其引证的「MIGRATION.md 常设策略」经亲验不存在,采纳的
201
- * 依据是本仓 assert*Schema 探针族的真先例与失败方向本身)。
216
+ * 依据是本仓 assert*Schema 探针族的真先例与失败方向本身)。S-160 起判据对 {@link CHECKPOINT_ADDED_COLUMNS}
217
+ * **整张列册**跑(见那张表的头注:归一的理由)。
202
218
  *
203
219
  * 病:本仓不发 `ALTER TABLE`(SCHEMA POLICY:改列就改 CREATE + 删库重建)。一台**没删表**就升上来的
204
- * 部署,`CREATE TABLE IF NOT EXISTS` 对存量 `checkpoint` 是空操作 —— 本批新增的 `has_bidi_controls`
205
- * 列不在,而 `put()` 的 INSERT 与 `listPending` 的 SELECT **无条件**引用它。没有这道断言,服务照常过
206
- * 启动与健康检查,直到:每一次 durable park 落行在 `put()` 撞 unknown column(挂起面整条失效),
207
- * `GET /v1/approvals` 与 `/stream` 的每一次轮询 500 —— 运行期才响,且首撞点在用户的审批路径上。
220
+ * 部署,`CREATE TABLE IF NOT EXISTS` 对存量 `checkpoint` 是空操作 —— 后加的列不在,而写口与读口
221
+ * **无条件**引用它们。没有这道断言,服务照常过启动与健康检查,直到运行期才响,且首撞点在用户的
222
+ * 审批 / resume 路径上。
208
223
  *
209
224
  * 判据 = **能力探测**(恒空的 `WHERE 1=0` 读)+ **方言的缺列错误码**({@link isMissingColumnError},
210
225
  * 判据属主 `sql-errors.ts`);其余错误**原样 rethrow**,一个字都不加工 —— 一句破坏性的「去 DROP TABLE」
@@ -214,7 +229,7 @@ export declare function pendingRowPrecedes(a: {
214
229
  * store 装配段、路由装配之前;**无条件**跑(与 [ref] tool_result 探针同档)——checkpoint 表在两方言的
215
230
  * 中央 DDL 里恒在,SQL 后端一开它就是 durable park/审批队列的承载面,不存在「协议不上场」的豁免形。
216
231
  */
217
- export declare function assertCheckpointBidiColumnSchema(query: (sql: string) => Promise<{
232
+ export declare function assertCheckpointColumnSchema(query: (sql: string) => Promise<{
218
233
  rows: Record<string, unknown>[];
219
234
  }>, dialect: SqlDialect): Promise<void>;
220
235
  /**
@@ -321,6 +336,17 @@ export interface DecidedApprovalRecord {
321
336
  * 铸法见 {@link approvalPayloadFingerprint}(两条车道共用同一只,免得指纹口径漂)。
322
337
  */
323
338
  payloadFp: string | null;
339
+ /**
340
+ * S-160(core 7.7.0 [ref])—— 这条已决行上**已决动作最后被怎么处置**的结算记录,`null` = **未知**。
341
+ *
342
+ * 🔴 缺席只有一个含义:未知,**绝不是**「放行过」。`decision` 说的是决定(人批了没有),这一格说的是
343
+ * 那次决定在 resume 腿上执行时的处置(部署自己的复核可能在 post-CAS 把人批过的、改过参数的调用否掉,
344
+ * 于是 `decision: "approve"` 与 denied/policy 的处置**同时**在一行上成立)。[ref] 的病正是「流断之后
345
+ * 从行重建这条 run 的消费者只读得到前半句」——decide 幂等回放正是那种消费者。
346
+ *
347
+ * 铸法 = core 的 {@link executionVerdict} **单口**(两条车道共用),不许任何读点自己判列的在场性。
348
+ */
349
+ executionOutcome: GateOutcome | null;
324
350
  }
325
351
  /**
326
352
  * [ref] 件1 —— {@link SqlCheckpointStore.findDecidedApprovalsForBinding} 的扫描上界。
@@ -417,6 +443,17 @@ export declare class SqlCheckpointStore implements CheckpointStore {
417
443
  readonly redecision: {
418
444
  readonly reopen: true;
419
445
  };
446
+ /**
447
+ * `CheckpointStore.execution` 声明(core 7.7.0 [ref];S-160)——**必填**,且必须恰是 `{ outcome: true }`:
448
+ * 店在此承诺 {@link SqlCheckpointStore.recordExecutionOutcome} 是契约描述的那条真 CAS(谓词、四词、
449
+ * 冲突抛),且 {@link SqlCheckpointStore.reopen} 认这条记录。与 `durability`/`fidelity`/`redecision`
450
+ * 三格同一条「声明而非鸭子判」姿势,区别只在这一格**不可缺席**:缺席的店会被 core 在 store 席的
451
+ * 第一次读(`resolveCheckpointStore`)以 `config.invalid_checkpoint_store` 拒启 —— 那是契约的正确行为,
452
+ * 因为一台记不下「已决动作最后被怎么处置」的部署,它手里每一条 resolved 行在这条轴上都永远读不出来。
453
+ */
454
+ readonly execution: {
455
+ readonly outcome: true;
456
+ };
420
457
  constructor(db: SqlDriver, logger?: {
421
458
  info?(msg: string, meta?: unknown): void;
422
459
  } | undefined);
@@ -485,6 +522,23 @@ export declare class SqlCheckpointStore implements CheckpointStore {
485
522
  * is validated against it — clearing it would defeat the winner-replay invariant.
486
523
  */
487
524
  reopen(token: CheckpointToken, scope: string, reason: ReopenReason): Promise<boolean>;
525
+ /**
526
+ * core 7.7.0 [ref](S-160)—— 记录**已决动作在 resume 腿上最后被怎么处置**:那一次调用的 `tool_end` 帧
527
+ * 携带的 {@link GateOutcome}。契约(答案闭集四词 / 幂等 / 冲突抛 / 一个字都不碰 status·rev·outcome·
528
+ * reopen_reason)的**判词逻辑一行都不自铸**:全部消费 core 导出的
529
+ * {@link executionRecordDisposition} —— 它是每个后端共用的那一份「CAS 谓词 + 幂等/冲突两臂」,
530
+ * 本店只留 commit 那一半(一条 `UPDATE … WHERE`)。
531
+ *
532
+ * 形 = **先读后 CAS**(与 {@link SqlCheckpointStore.setPendingSteer} 同款,理由同款):判词是**按行读**
533
+ * 出来的(要分辨 already_recorded / conflict / not_resolved / absent 四种,SQL 的 affected 只会告诉你
534
+ * 「没打中」),所以读一次交给 core 判,再把 commit 那一半压回一条带完整谓词的 UPDATE。两条语句之间那道窗
535
+ * 由 UPDATE 自己的谓词兜住:CAS 落空就重读重判(下一轮的读会分辨出到底是谁抢先了)。给足冗余仍全败 ⇒
536
+ * **抛**,绝不返回一个四词之一 —— 那等于替一次没发生的写编一个答案。
537
+ *
538
+ * 🔴 `execution_outcome` 坏 cell 在这里与 `get()` 同款 **抛**(strict parse):静默当缺席会让一份不同的
539
+ * 记录覆盖已经在案的那份,而记录物理不可变正是这条契约的全部意义。
540
+ */
541
+ recordExecutionOutcome(token: CheckpointToken, scope: string, gate: GateOutcome): Promise<ExecutionOutcomeRecordWord>;
488
542
  /**
489
543
  * [ref] D-A (durable steering) —— **core 5.14.0([ref] BREAKING)起是「追加进一条有界有序队列」**,
490
544
  * 不再是「盖掉单座」。两个人同时给一条挂起的 run 转向,旧语义会让第二条**静默销毁**第一条(一条没人
@@ -560,6 +614,13 @@ export declare class SqlCheckpointStore implements CheckpointStore {
560
614
  /** Pending checkpoints for the operator queue — NON-secret fields only (never the token). `taskId` is
561
615
  * joined from task_active (the JOIN KEY to the run/trace — a suspended run KEEPS its
562
616
  * session claim, so the join is live for every pending row; null only in pathological windows). */
617
+ /**
618
+ * S-160 显式记账 —— **本口与 {@link SqlCheckpointStore.listByScope} 都不投执行判词,这是结构性的不是遗漏**:
619
+ * 两口的谓词都硬 `status='pending'`,而执行记录的 CAS 谓词是 `status='resolved' AND execution_outcome IS NULL`
620
+ * ⇒ 一条 pending 行**按构造**不可能带记录;反向也封死了(记过的行 `reopen` 拒,永远回不到 pending)。
621
+ * 所以这两条读面上「没有 `executionOutcome`」不是「缺席=未知」,而是「这一格在这条读面上不存在」。
622
+ * 真正需要它的读面是**已决行**的那一条:`findDecidedApprovalsForBinding`(decide 幂等回放),见那里。
623
+ */
563
624
  listPending(scope?: string): Promise<PendingCheckpoint[]>;
564
625
  /**
565
626
  * [ref] #2 (scheduler seam, core 1.105): a scope's PENDING checkpoints as lightweight
@@ -1,7 +1,7 @@
1
1
  import { contentKindOf } from "../approval-content-kind.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
4
- import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, } from "@sema-agent/core";
4
+ import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, ExecutionOutcomeConflictError, executionRecordDisposition, executionVerdict, } from "@sema-agent/core";
5
5
  import { redactDeep, redactSecrets } from "../trace/redact.js";
6
6
  import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema, backfillLegacyBatchMemberKind, screenUncoveredDetailSeat } from "../approval-card.js";
7
7
  import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
@@ -32,23 +32,41 @@ export function pendingRowPrecedes(a, b, pick) {
32
32
  return a.createdAt < b.createdAt || (a.createdAt === b.createdAt && a.token < b.token);
33
33
  return a.createdAt > b.createdAt || (a.createdAt === b.createdAt && a.token > b.token);
34
34
  }
35
- export async function assertCheckpointBidiColumnSchema(query, dialect) {
36
- try {
37
- await query("SELECT has_bidi_controls FROM checkpoint WHERE 1=0");
38
- }
39
- catch (err) {
40
- if (!isMissingColumnError(err, dialect))
41
- throw err;
42
- throw new Error("checkpoint is missing the S-30 bidi-presence column (has_bidi_controls) — refusing to start. " +
43
- "The park-mint write (put) and the operator-queue reads (GET /v1/approvals, /v1/approvals/stream) name the column " +
44
- "unconditionally, so on this table every durable suspend fails at first park and every queue poll 500s — at runtime, " +
45
- "after a healthy-looking boot. This repository ships no ALTER TABLE migrations, so the table must be recreated: run " +
46
- "`DROP TABLE checkpoint;` on the " +
47
- (dialect === "pg" ? "PostgreSQL" : "MySQL-protocol") +
48
- " backend and restart — the schema is recreated at boot. The cost is bounded but real: the table holds IN-FLIGHT " +
49
- "durable parks (suspended approvals/reviews awaiting a decision) — those cards are lost and their runs must be " +
50
- "re-triggered; `checkpoint_ctx`, run rows and every other persistent face are untouched. " +
51
- `Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
35
+ export const CHECKPOINT_ADDED_COLUMNS = [
36
+ {
37
+ column: "has_bidi_controls",
38
+ breaksAt: "the park-mint write (put) and the operator-queue reads (GET /v1/approvals, /v1/approvals/stream) name it " +
39
+ "unconditionally, so every durable suspend fails at first park and every queue poll 500s",
40
+ },
41
+ {
42
+ column: "execution_outcome",
43
+ breaksAt: "the execution-outcome record (CheckpointStore.recordExecutionOutcome, core 7.7.0 #602) writes it under a CAS and " +
44
+ "`reopen` reads it as a predicate, so every resumed approval fails to file how the decided action was disposed and " +
45
+ "every reopen statement errors",
46
+ },
47
+ {
48
+ column: "execution_at_ms",
49
+ breaksAt: "it is written in the same CAS as `execution_outcome` (the record and its stamp are one write group)",
50
+ },
51
+ ];
52
+ export async function assertCheckpointColumnSchema(query, dialect) {
53
+ for (const { column, breaksAt } of CHECKPOINT_ADDED_COLUMNS) {
54
+ try {
55
+ await query(`SELECT ${column} FROM checkpoint WHERE 1=0`);
56
+ }
57
+ catch (err) {
58
+ if (!isMissingColumnError(err, dialect))
59
+ throw err;
60
+ throw new Error(`checkpoint is missing the column ${column} — refusing to start. ` +
61
+ `On this table ${breaksAt} — at runtime, after a healthy-looking boot. ` +
62
+ "This repository ships no ALTER TABLE migrations, so the table must be recreated: run " +
63
+ "`DROP TABLE checkpoint;` on the " +
64
+ (dialect === "pg" ? "PostgreSQL" : "MySQL-protocol") +
65
+ " backend and restart — the schema is recreated at boot. The cost is bounded but real: the table holds IN-FLIGHT " +
66
+ "durable parks (suspended approvals/reviews awaiting a decision) — those cards are lost and their runs must be " +
67
+ "re-triggered; `checkpoint_ctx`, run rows and every other persistent face are untouched. " +
68
+ `Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
69
+ }
52
70
  }
53
71
  }
54
72
  const MAX_RULE_OFFER_SCAN = 32;
@@ -266,6 +284,7 @@ export function readPendingSteerColumns(seatRaw, queueRaw) {
266
284
  };
267
285
  }
268
286
  const STEER_CAS_ATTEMPTS = 8;
287
+ const EXECUTION_RECORD_CAS_ATTEMPTS = 8;
269
288
  export class SqlCheckpointStore {
270
289
  db;
271
290
  logger;
@@ -273,6 +292,7 @@ export class SqlCheckpointStore {
273
292
  durability = "durable";
274
293
  fidelity = "json";
275
294
  redecision = { reopen: true };
295
+ execution = { outcome: true };
276
296
  constructor(db, logger) {
277
297
  this.db = db;
278
298
  this.logger = logger;
@@ -318,14 +338,17 @@ export class SqlCheckpointStore {
318
338
  const toolInput = boundedToolInput(pa?.args);
319
339
  const ruleOffers = boundedRuleOffers(pa?.ruleOffers);
320
340
  const hasBidiControls = pa?.hasBidiControls === true ? 1 : null;
341
+ const executionOutcome = cp.executionOutcome === undefined ? null : this.json(cp.executionOutcome, "execution outcome");
342
+ const executionAtMs = cp.executionOutcome === undefined ? null : (cp.executionAtMs ?? null);
321
343
  try {
322
- await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor, rule_suggestions, has_bidi_controls) " +
323
- "VALUES (?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor, rule_suggestions, has_bidi_controls) " +
324
- "VALUES ($1,$2,$3,$4,'pending',$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13,$14,$15,$16)"), [
344
+ await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor, rule_suggestions, has_bidi_controls, rev, reopen_reason, execution_outcome, execution_at_ms) " +
345
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor, rule_suggestions, has_bidi_controls, rev, reopen_reason, execution_outcome, execution_at_ms) " +
346
+ "VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9::jsonb,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20::jsonb,$21)"), [
325
347
  token,
326
348
  cp.scope,
327
349
  cp.sessionId,
328
350
  version,
351
+ cp.status,
329
352
  pa?.toolName ?? null,
330
353
  pa?.toolCallId ?? null,
331
354
  toolInput === null ? null : this.json(toolInput, "approval tool_input"),
@@ -338,6 +361,10 @@ export class SqlCheckpointStore {
338
361
  ((g) => (g?.riskDescriptor ? this.json(g.riskDescriptor, "risk descriptor") : null))(cp.gate),
339
362
  ruleOffers === undefined ? null : this.json(ruleOffers, "rule offers"),
340
363
  hasBidiControls,
364
+ cp.rev ?? 0,
365
+ cp.reopenReason ?? null,
366
+ executionOutcome,
367
+ executionAtMs,
341
368
  ]);
342
369
  }
343
370
  catch (e) {
@@ -348,7 +375,7 @@ export class SqlCheckpointStore {
348
375
  }
349
376
  }
350
377
  async get(token) {
351
- const { rows } = await this.db.query(this.q("SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer, pending_steer_queue FROM checkpoint WHERE token = ?", "SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer, pending_steer_queue FROM checkpoint WHERE token = $1"), [token]);
378
+ const { rows } = await this.db.query(this.q("SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer, pending_steer_queue, execution_outcome, execution_at_ms FROM checkpoint WHERE token = ?", "SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer, pending_steer_queue, execution_outcome, execution_at_ms FROM checkpoint WHERE token = $1"), [token]);
352
379
  const r = rows[0];
353
380
  if (!r)
354
381
  return null;
@@ -363,6 +390,12 @@ export class SqlCheckpointStore {
363
390
  cp.resolvedOutcome = winnerFromOutcome(outcomeRaw);
364
391
  cp.reopenReason = r.reopen_reason ?? undefined;
365
392
  cp.rev = r.rev == null ? 0 : Number(r.rev);
393
+ const executionOutcome = parseJson(r.execution_outcome);
394
+ if (executionOutcome !== null) {
395
+ cp.executionOutcome = executionOutcome;
396
+ if (r.execution_at_ms != null)
397
+ cp.executionAtMs = Number(r.execution_at_ms);
398
+ }
366
399
  const steer = readPendingSteerColumns(r.pending_steer, r.pending_steer_queue);
367
400
  if (steer.lossy) {
368
401
  this.logger?.info?.("checkpoint_pending_steer_unreadable", { tokenFp: tokenFingerprint(token) });
@@ -392,9 +425,32 @@ export class SqlCheckpointStore {
392
425
  return res.affected === 1;
393
426
  }
394
427
  async reopen(token, scope, reason) {
395
- const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'pending', reopen_reason = ?, rev = rev + 1 WHERE token = ? AND scope = ? AND status = 'resolved'", "UPDATE checkpoint SET status = 'pending', reopen_reason = $1, rev = rev + 1 WHERE token = $2 AND scope = $3 AND status = 'resolved'"), [reason, token, scope]);
428
+ const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'pending', reopen_reason = ?, rev = rev + 1 WHERE token = ? AND scope = ? AND status = 'resolved' AND execution_outcome IS NULL", "UPDATE checkpoint SET status = 'pending', reopen_reason = $1, rev = rev + 1 WHERE token = $2 AND scope = $3 AND status = 'resolved' AND execution_outcome IS NULL"), [reason, token, scope]);
396
429
  return res.affected === 1;
397
430
  }
431
+ async recordExecutionOutcome(token, scope, gate) {
432
+ for (let attempt = 0; attempt < EXECUTION_RECORD_CAS_ATTEMPTS; attempt++) {
433
+ const { rows } = await this.db.query(this.q("SELECT scope, status, execution_outcome FROM checkpoint WHERE token = ?", "SELECT scope, status, execution_outcome FROM checkpoint WHERE token = $1"), [token]);
434
+ const row = rows[0];
435
+ const facts = row === undefined
436
+ ? undefined
437
+ : {
438
+ scope: String(row.scope),
439
+ status: row.status,
440
+ ...((v) => (v === null ? {} : { executionOutcome: v }))(parseJson(row.execution_outcome)),
441
+ };
442
+ const disposition = executionRecordDisposition(facts, scope, gate);
443
+ if (disposition === "conflict")
444
+ throw new ExecutionOutcomeConflictError(facts.executionOutcome, gate);
445
+ if (disposition !== "commit")
446
+ return disposition;
447
+ const res = await this.db.query(this.q("UPDATE checkpoint SET execution_outcome = ?, execution_at_ms = ? WHERE token = ? AND scope = ? AND status = 'resolved' AND execution_outcome IS NULL", "UPDATE checkpoint SET execution_outcome = $1::jsonb, execution_at_ms = $2 WHERE token = $3 AND scope = $4 AND status = 'resolved' AND execution_outcome IS NULL"), [this.json(gate, "execution outcome"), Date.now(), token, scope]);
448
+ if (res.affected === 1)
449
+ return "recorded";
450
+ }
451
+ throw new Error(`recordExecutionOutcome lost the execution-record CAS ${EXECUTION_RECORD_CAS_ATTEMPTS} times on checkpoint ${tokenFingerprint(token)} — ` +
452
+ "refusing to answer with one of the four contract words (that would invent an account of a write that did not happen)");
453
+ }
398
454
  async setPendingSteer(token, scope, steer) {
399
455
  const clean = validatePendingSteer(steer);
400
456
  for (let attempt = 0; attempt < STEER_CAS_ATTEMPTS; attempt++) {
@@ -529,8 +585,8 @@ export class SqlCheckpointStore {
529
585
  return out;
530
586
  }
531
587
  async findDecidedApprovalsForBinding(sessionId, boundCallId) {
532
- const { rows } = await this.db.query(this.q("SELECT scope, bound_input_hash, outcome FROM checkpoint " +
533
- `WHERE session_id=? AND tool_call_id=? AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`, "SELECT scope, bound_input_hash, outcome FROM checkpoint " +
588
+ const { rows } = await this.db.query(this.q("SELECT scope, bound_input_hash, outcome, execution_outcome FROM checkpoint " +
589
+ `WHERE session_id=? AND tool_call_id=? AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`, "SELECT scope, bound_input_hash, outcome, execution_outcome FROM checkpoint " +
534
590
  `WHERE session_id=$1 AND tool_call_id=$2 AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`), [sessionId, boundCallId]);
535
591
  return rows.map((r) => {
536
592
  let winner;
@@ -541,11 +597,20 @@ export class SqlCheckpointStore {
541
597
  catch (e) {
542
598
  this.logger?.info?.("checkpoint_decided_outcome_unreadable", { sessionId, error: e instanceof Error ? e.message : String(e) });
543
599
  }
600
+ let executed = null;
601
+ try {
602
+ const verdict = executionVerdict({ ...((v) => (v === null ? {} : { executionOutcome: v }))(parseJson(r.execution_outcome)) });
603
+ executed = verdict.kind === "executed" ? verdict.gate : null;
604
+ }
605
+ catch (e) {
606
+ this.logger?.info?.("checkpoint_execution_outcome_unreadable", { sessionId, error: e instanceof Error ? e.message : String(e) });
607
+ }
544
608
  return {
545
609
  scope: String(r.scope),
546
610
  boundInputHash: r.bound_input_hash == null ? null : String(r.bound_input_hash),
547
611
  decision: approvalDecisionOfWinner(winner, boundCallId),
548
612
  payloadFp: winner === undefined ? null : approvalPayloadFingerprint(winner),
613
+ executionOutcome: executed,
549
614
  };
550
615
  });
551
616
  }