@sema-agent/server 7.93.4 → 7.93.6

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 (38) hide show
  1. package/dist/bake-runner/main.d.ts +28 -1
  2. package/dist/bake-runner/main.js +122 -38
  3. package/dist/bake-runner/runner.d.ts +85 -7
  4. package/dist/bake-runner/runner.js +116 -27
  5. package/dist/boot/exit-boundary.d.ts +22 -0
  6. package/dist/boot/exit-boundary.js +73 -0
  7. package/dist/boot/shutdown.d.ts +2 -2
  8. package/dist/boot/shutdown.js +5 -62
  9. package/dist/hooks/hook-runner.js +5 -1
  10. package/dist/observability/fail-open.d.ts +16 -0
  11. package/dist/observability/fail-open.js +16 -0
  12. package/dist/plugins/approval-ask-store-sql.d.ts +1 -1
  13. package/dist/plugins/approval-ask-store-sql.js +36 -19
  14. package/dist/plugins/background-agent-store-sql.js +36 -16
  15. package/dist/plugins/checkpoint-store-sql.d.ts +5 -1
  16. package/dist/plugins/checkpoint-store-sql.js +111 -66
  17. package/dist/plugins/device-store-sql.d.ts +10 -0
  18. package/dist/plugins/device-store-sql.js +12 -6
  19. package/dist/plugins/host-platform.d.ts +70 -6
  20. package/dist/plugins/host-platform.js +14 -3
  21. package/dist/plugins/leader-run-store-sql.js +16 -6
  22. package/dist/plugins/one-time-migrations.d.ts +3 -0
  23. package/dist/plugins/one-time-migrations.js +16 -4
  24. package/dist/plugins/permission-rule-store-sql.d.ts +1 -0
  25. package/dist/plugins/permission-rule-store-sql.js +37 -28
  26. package/dist/plugins/remote-env-host.js +9 -21
  27. package/dist/plugins/run-store-sql.d.ts +1 -1
  28. package/dist/plugins/run-store-sql.js +41 -10
  29. package/dist/plugins/session-policy-store-sql.d.ts +24 -0
  30. package/dist/plugins/session-policy-store-sql.js +17 -18
  31. package/dist/plugins/sql-json-column.d.ts +177 -0
  32. package/dist/plugins/sql-json-column.js +74 -0
  33. package/dist/plugins/task-list-store-sql.d.ts +13 -1
  34. package/dist/plugins/task-list-store-sql.js +23 -10
  35. package/dist/plugins/workflow-run-store-sql.js +14 -4
  36. package/dist/run-local.d.ts +15 -0
  37. package/dist/run-local.js +32 -0
  38. package/package.json +1 -1
@@ -0,0 +1,73 @@
1
+ import { defaultTaskRegistry } from "@sema-agent/core";
2
+ import { liveHostForegroundChildCount, reapHostForegroundChildren } from "../plugins/host-platform.js";
3
+ import { liveEphemeralHostWorkspaceCount, reapEphemeralHostWorkspaces } from "../plugins/host-workspace-registry.js";
4
+ export function installProcessExitBoundary({ logger }) {
5
+ const reapOwnChildProcesses = () => {
6
+ try {
7
+ const reaped = defaultTaskRegistry.reapAllSessionBackground();
8
+ if (reaped > 0)
9
+ logger.info("shutdown_background_reaped", { reaped });
10
+ }
11
+ catch (err) {
12
+ logger.warn("shutdown_background_reap_failed", { err: String(err) });
13
+ }
14
+ try {
15
+ const fg = reapHostForegroundChildren();
16
+ if (fg.signalled > 0 || fg.alreadyDead > 0)
17
+ logger.info("shutdown_foreground_reaped", { signalled: fg.signalled, alreadyDead: fg.alreadyDead });
18
+ if (fg.failed.length > 0)
19
+ logger.warn("shutdown_foreground_reap_incomplete", { groups: fg.failed });
20
+ }
21
+ catch (err) {
22
+ logger.warn("shutdown_foreground_reap_failed", { err: String(err) });
23
+ }
24
+ };
25
+ const reapOwnHostWorkspaces = () => {
26
+ try {
27
+ const { removed, failed } = reapEphemeralHostWorkspaces();
28
+ if (removed.length > 0)
29
+ logger.info("shutdown_host_workspaces_reaped", { removed: removed.length });
30
+ if (failed.length > 0)
31
+ logger.warn("shutdown_host_workspace_reap_incomplete", { dirs: failed.map((f) => f.dir) });
32
+ }
33
+ catch (err) {
34
+ logger.warn("shutdown_host_workspace_reap_failed", { err: String(err) });
35
+ }
36
+ };
37
+ const onProcessExit = () => {
38
+ const onBooks = { foregroundChildren: liveHostForegroundChildCount(), hostWorkspaces: liveEphemeralHostWorkspaceCount() };
39
+ try {
40
+ reapOwnChildProcesses();
41
+ }
42
+ catch (err) {
43
+ console.error("shutdown_exit_boundary_child_reap_threw", err);
44
+ }
45
+ try {
46
+ reapOwnHostWorkspaces();
47
+ }
48
+ catch (err) {
49
+ console.error("shutdown_exit_boundary_workspace_reap_threw", err);
50
+ }
51
+ const left = { foregroundChildren: liveHostForegroundChildCount(), hostWorkspaces: liveEphemeralHostWorkspaceCount() };
52
+ try {
53
+ logger.info("shutdown_exit_boundary", { ...onBooks, leftForegroundChildren: left.foregroundChildren, leftHostWorkspaces: left.hostWorkspaces });
54
+ if (left.foregroundChildren > 0 || left.hostWorkspaces > 0)
55
+ logger.warn("shutdown_exit_boundary_incomplete", left);
56
+ }
57
+ catch (err) {
58
+ console.error("shutdown_exit_boundary_log_threw", err);
59
+ }
60
+ };
61
+ process.once("exit", onProcessExit);
62
+ let disposed = false;
63
+ return {
64
+ reapChildProcessesEarly: reapOwnChildProcesses,
65
+ dispose: () => {
66
+ if (disposed)
67
+ return;
68
+ disposed = true;
69
+ process.removeListener("exit", onProcessExit);
70
+ },
71
+ };
72
+ }
73
+ //# sourceMappingURL=exit-boundary.js.map
@@ -12,8 +12,8 @@
12
12
  * "第二个信号"的语义读数。
13
13
  * 3. `clearInterval(reaper)` 必须是 hardShutdown 的第一件事(在 `server.close()` 之前),否则
14
14
  * 收尾期还会有 reaper tick 打向正在关闭的池。
15
- * 4. **进程级监听一律成对**([ref] 二轮件4):本函数装的六只(三信号 + [ref]① 双钩 + S-557 退出边界钩)
16
- * 只能经返回的
15
+ * 4. **进程级监听一律成对**([ref] 二轮件4):本函数装的六只(三信号 + [ref]① 双钩 + S-557 退出边界钩
16
+ * —— S-560 起后者由 {@link installProcessExitBoundary} 代装,摘除仍并进同一把手)只能经返回的
17
17
  * {@link ShutdownHandle} 摘 —— 调用方手写「摘三信号」的收尾会把双钩漏在进程上,而陈旧的那只
18
18
  * rejection 钩仍握着 `process.exit(1)`,会对后续无关的 rejection 把进程打掉。
19
19
  */
@@ -1,9 +1,8 @@
1
- import { Runner, defaultTaskRegistry } from "@sema-agent/core";
1
+ import { Runner } from "@sema-agent/core";
2
2
  import { writeCrashLast, clearCrashLast, readCrashLast } from "./crash-last.js";
3
3
  import { createSighupIdleHandler } from "../sighup-idle.js";
4
4
  import { createParentWatch } from "../parent-watch.js";
5
- import { liveHostForegroundChildCount, reapHostForegroundChildren } from "../plugins/host-platform.js";
6
- import { liveEphemeralHostWorkspaceCount, reapEphemeralHostWorkspaces } from "../plugins/host-workspace-registry.js";
5
+ import { installProcessExitBoundary } from "./exit-boundary.js";
7
6
  import { createEngineLeaseTracker } from "./engine-lease.js";
8
7
  import { writeExitLast, readExitLast, clearExitLast } from "./exit-last.js";
9
8
  import { isScriptRealmRejection, describeRejectionReason } from "../orchestration/hardened-vm-runner.js";
@@ -32,63 +31,7 @@ export function installShutdownHandlers(ctx) {
32
31
  if (dataRoot)
33
32
  writeCrashLast(dataRoot, { version, inflight: drainState.inflight?.(), err });
34
33
  };
35
- const reapOwnChildProcesses = () => {
36
- try {
37
- const reaped = defaultTaskRegistry.reapAllSessionBackground();
38
- if (reaped > 0)
39
- logger.info("shutdown_background_reaped", { reaped });
40
- }
41
- catch (err) {
42
- logger.warn("shutdown_background_reap_failed", { err: String(err) });
43
- }
44
- try {
45
- const fg = reapHostForegroundChildren();
46
- if (fg.signalled > 0 || fg.alreadyDead > 0)
47
- logger.info("shutdown_foreground_reaped", { signalled: fg.signalled, alreadyDead: fg.alreadyDead });
48
- if (fg.failed.length > 0)
49
- logger.warn("shutdown_foreground_reap_incomplete", { groups: fg.failed });
50
- }
51
- catch (err) {
52
- logger.warn("shutdown_foreground_reap_failed", { err: String(err) });
53
- }
54
- };
55
- const reapOwnHostWorkspaces = () => {
56
- try {
57
- const { removed, failed } = reapEphemeralHostWorkspaces();
58
- if (removed.length > 0)
59
- logger.info("shutdown_host_workspaces_reaped", { removed: removed.length });
60
- if (failed.length > 0)
61
- logger.warn("shutdown_host_workspace_reap_incomplete", { dirs: failed.map((f) => f.dir) });
62
- }
63
- catch (err) {
64
- logger.warn("shutdown_host_workspace_reap_failed", { err: String(err) });
65
- }
66
- };
67
- const onProcessExit = () => {
68
- const onBooks = { foregroundChildren: liveHostForegroundChildCount(), hostWorkspaces: liveEphemeralHostWorkspaceCount() };
69
- try {
70
- reapOwnChildProcesses();
71
- }
72
- catch (err) {
73
- console.error("shutdown_exit_boundary_child_reap_threw", err);
74
- }
75
- try {
76
- reapOwnHostWorkspaces();
77
- }
78
- catch (err) {
79
- console.error("shutdown_exit_boundary_workspace_reap_threw", err);
80
- }
81
- const left = { foregroundChildren: liveHostForegroundChildCount(), hostWorkspaces: liveEphemeralHostWorkspaceCount() };
82
- try {
83
- logger.info("shutdown_exit_boundary", { ...onBooks, leftForegroundChildren: left.foregroundChildren, leftHostWorkspaces: left.hostWorkspaces });
84
- if (left.foregroundChildren > 0 || left.hostWorkspaces > 0)
85
- logger.warn("shutdown_exit_boundary_incomplete", left);
86
- }
87
- catch (err) {
88
- console.error("shutdown_exit_boundary_log_threw", err);
89
- }
90
- };
91
- process.once("exit", onProcessExit);
34
+ const exitBoundary = installProcessExitBoundary({ logger });
92
35
  const onUncaughtException = (err) => {
93
36
  try {
94
37
  writeLastWords(err);
@@ -151,7 +94,7 @@ export function installShutdownHandlers(ctx) {
151
94
  subRunner.sessions.dispose();
152
95
  void lspManager?.dispose?.();
153
96
  void workflowNotifyJournal?.close?.();
154
- reapOwnChildProcesses();
97
+ exitBoundary.reapChildProcessesEarly();
155
98
  void fleetClient?.stop();
156
99
  server.close(() => {
157
100
  void retentionTeardown.then(() => (backend ? backend.close() : Promise.resolve())).finally(() => process.exit(0));
@@ -248,7 +191,7 @@ export function installShutdownHandlers(ctx) {
248
191
  if (disposed)
249
192
  return;
250
193
  disposed = true;
251
- process.removeListener("exit", onProcessExit);
194
+ exitBoundary.dispose();
252
195
  process.removeListener("uncaughtException", onUncaughtException);
253
196
  process.removeListener("unhandledRejection", onUnhandledRejection);
254
197
  process.removeListener("SIGTERM", drainThenShutdown);
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { hostShell, resolveHostShell } from "../plugins/host-platform.js";
2
+ import { hostShell, resolveHostShell, adoptHostForegroundChild } from "../plugins/host-platform.js";
3
3
  import { HooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_EVENT_OWNER, } from "@sema-agent/settings-schema/hooks";
4
4
  import { boundedNoticeText } from "../fleet/fleet-bus.js";
5
5
  import { recordFailOpen } from "../observability/fail-open.js";
@@ -271,6 +271,7 @@ function runCommandHook(entry, payload, ctx) {
271
271
  resolve({ code: null, stdout: "", stderr: "", timedOut: false, spawnError: String(e) });
272
272
  return;
273
273
  }
274
+ const ticket = adoptHostForegroundChild(child);
274
275
  let stdout = "";
275
276
  let stderr = "";
276
277
  let timedOut = false;
@@ -280,6 +281,7 @@ function runCommandHook(entry, payload, ctx) {
280
281
  return;
281
282
  settled = true;
282
283
  clearTimeout(timer);
284
+ ticket?.release();
283
285
  resolve({ code, stdout, stderr, timedOut });
284
286
  };
285
287
  const timer = setTimeout(() => {
@@ -326,6 +328,7 @@ function fireAsyncCommandHook(entry, payload, ctx, event) {
326
328
  stdio: ["pipe", "ignore", rewake ? "pipe" : "ignore"],
327
329
  detached: true,
328
330
  });
331
+ const ticket = adoptHostForegroundChild(child);
329
332
  let killedByTimeout = false;
330
333
  const timer = setTimeout(() => {
331
334
  killedByTimeout = true;
@@ -355,6 +358,7 @@ function fireAsyncCommandHook(entry, payload, ctx, event) {
355
358
  });
356
359
  child.on("close", (code) => {
357
360
  clearTimeout(timer);
361
+ ticket?.release();
358
362
  noteAsyncOutcome(entry, ctx, event, payload, { code, rewake, killedByTimeout });
359
363
  if (!rewake || code !== 2)
360
364
  return;
@@ -121,6 +121,22 @@ export declare const FAIL_OPEN_TAGS: {
121
121
  readonly cls: "F";
122
122
  readonly note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.risk_descriptor` 这一格**不是合法 JSON 文本**(手改过的行 / 回滚残留 / 列语义漂;与同一 `rows.map` 里的 `rule_suggestions` 是同族坏法,两方言都是 TEXT 列)。放行的最坏后果 = **这一行**的分诊 descriptor 落 null:severity 折 0 排到有 descriptor 的行之后、`shadowedRule` 无可脱敏、读面派生的 `governanceForced` 徽章不亮 —— 三者都是**展示/分诊**,不是执法判据(门早已 park,真按 `shellGateDoctrine` 判的 `active-run-conflict.ts` 读的是 checkpoint blob 的 `get()`,不经本读面),且 `governanceForced` 的成文语义本就是「缺席 = 没有治理来源的**证据**」。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),连同其余所有行一起消失。故 F 类;tag 与候选那格**分开计**,并计会让「哪一格在坏」在遥测里读不出来。";
123
123
  };
124
+ readonly "server.sql-json.checkpoint.pending-steer-queue-lossy": {
125
+ readonly cls: "F";
126
+ readonly note: "S-550(`plugins/checkpoint-store-sql.ts` 的 `get()`,core 5.14.0 #147 的耐久转向队列):一条挂起 run 的 `checkpoint.pending_steer_queue` 这一格**没能完整读出来**(不是合法 JSON 文本 / 不是数组 / 某一条目的四个必备键不合形)⇒ 读不出的那部分**丢掉**,run 照常 resume。放行的最坏后果 = 一条已经 park 的操作员转向指令**没有**在这次 resume 上注入(人以为说过的话没送到);不放行的代价是这条挂起的 run **永远不可 resume** —— resume 是它唯一的出路,为一格读不出的转向把整条 run 变成砖是错误的失败方向(写侧刻意相反:`setPendingSteer` 往一份读不全的队列上追加会把读不出的条目永久抹掉,那里 fail-loud)。故 F 类。留痕从一条 `logger.info` 升成登记 tag 的理由:日志行在**遥测轴**上是零信号,「这台机器的转向队列一直读不出来」与「这台机器本来就没人 steer」在指标上完全同形。计数非零 = 去按日志里的 token 指纹查那一行。";
127
+ };
128
+ readonly "server.sql-json.checkpoint.outcome-unreadable": {
129
+ readonly cls: "F";
130
+ readonly note: "S-550(`plugins/checkpoint-store-sql.ts` 的 `findDecidedApprovalsForBinding`,`/decide` 的**幂等回放**腿):一条已决 checkpoint 行的 `outcome` 这一格读不出形(手改 / 撕裂写 / 迁移脚本改过)⇒ 这一行交出 `decision: null`(= 判别不出),调用方按「整次拒绝」处置。放行的最坏后果 = 一次 decide **重试**拿不到上次的判词,退回按新请求处理(而不是回放)—— 回放是锦上添花的腿,让一条读不出的 JSON 把一次重试变成 500 比这更坏;方向上没有任何批准被放宽(判不出就是判不出,调用方拒绝)。故 F 类。**只容忍体的问题**:`not-text`(读腿漏了投影)与连接/超时一律原样上抛 —— 那是码坏或故障,不是数据坏。";
131
+ };
132
+ readonly "server.sql-json.checkpoint.execution-outcome-unreadable": {
133
+ readonly cls: "F";
134
+ readonly note: "S-550(同上那条回放腿的第二格,`checkpoint.execution_outcome`,S-160 的执行判词):这一格读不出形 ⇒ 本行的 `executionOutcome` 交出 `null` = 「不知道这次批准有没有真的执行过」。放行的最坏后果 = 回放腿少一条「已执行」的证据,消费端据此**保守**处理(缺席在 core 的 `executionVerdict` 里读作 `unknown`,从来不读作「放行过」);不放行的代价与上一格同(一次 decide 重试 500)。⚠️ 同一格在 `get()` / `recordExecutionOutcome` 两条腿上**刻意相反**(抛,不容忍):那两条是结算记录的读写口,静默当缺席会让一份不同的记录覆盖已经在案的那份。tag 与上一格**分开计**:并计会让「哪一格在坏」在遥测里读不出来。";
135
+ };
136
+ readonly "server.sql-json.task-run.usage-result-unreadable": {
137
+ readonly cls: "F";
138
+ readonly note: "S-550(`plugins/run-store-sql.ts` 的 `usageScan`,`/v1/usage/*` 的扫窗腿):窗内某一行的 `task_run.result` 这一格读不出形 ⇒ 这一行**计 tasks、不计 token/cost**(`stats` 键缺席),其余行照常。放行的最坏后果 = 用量总数偏低一行的量(`projectUsageStats` 的成文降级口径逐字如此,三个后端共用);不放行的代价是**一行坏 cell 打掉整个租户的用量页**(扫窗上限 50k 行)。⚠️ 同一列在 `getRun` / `listRuns` 两条腿上**刻意相反**(抛,不容忍):那两条腿上这一格是提交方要的**答案**,把它折成「跑完了没有结果」是在替一次不知道的读编一个答案;而这里它只是一个加数。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。";
139
+ };
124
140
  readonly "server.retention.sweep-report-dropped": {
125
141
  readonly cls: "F";
126
142
  readonly note: "留存 sweep 的**失败上报**自己抛了(poison error 的 toString / 一条抛异常的 logger transport)⇒ 那一条 warn 与 stuck 指标发不出去。放行的最坏后果是**可观测性**损失而不是数据面损失:删除与审计的正确性完全在店事务里,本臂只影响运维看不看得见「这个域连败了」。不放行的后果反而更坏 —— 一个从上报里逃出去的异常会打断 per-domain 循环、饿死后面每一个域(reapers 的同款保护逐字同理由)。";
@@ -112,6 +112,22 @@ export const FAIL_OPEN_TAGS = {
112
112
  cls: "F",
113
113
  note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.risk_descriptor` 这一格**不是合法 JSON 文本**(手改过的行 / 回滚残留 / 列语义漂;与同一 `rows.map` 里的 `rule_suggestions` 是同族坏法,两方言都是 TEXT 列)。放行的最坏后果 = **这一行**的分诊 descriptor 落 null:severity 折 0 排到有 descriptor 的行之后、`shadowedRule` 无可脱敏、读面派生的 `governanceForced` 徽章不亮 —— 三者都是**展示/分诊**,不是执法判据(门早已 park,真按 `shellGateDoctrine` 判的 `active-run-conflict.ts` 读的是 checkpoint blob 的 `get()`,不经本读面),且 `governanceForced` 的成文语义本就是「缺席 = 没有治理来源的**证据**」。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),连同其余所有行一起消失。故 F 类;tag 与候选那格**分开计**,并计会让「哪一格在坏」在遥测里读不出来。",
114
114
  },
115
+ "server.sql-json.checkpoint.pending-steer-queue-lossy": {
116
+ cls: "F",
117
+ note: "S-550(`plugins/checkpoint-store-sql.ts` 的 `get()`,core 5.14.0 #147 的耐久转向队列):一条挂起 run 的 `checkpoint.pending_steer_queue` 这一格**没能完整读出来**(不是合法 JSON 文本 / 不是数组 / 某一条目的四个必备键不合形)⇒ 读不出的那部分**丢掉**,run 照常 resume。放行的最坏后果 = 一条已经 park 的操作员转向指令**没有**在这次 resume 上注入(人以为说过的话没送到);不放行的代价是这条挂起的 run **永远不可 resume** —— resume 是它唯一的出路,为一格读不出的转向把整条 run 变成砖是错误的失败方向(写侧刻意相反:`setPendingSteer` 往一份读不全的队列上追加会把读不出的条目永久抹掉,那里 fail-loud)。故 F 类。留痕从一条 `logger.info` 升成登记 tag 的理由:日志行在**遥测轴**上是零信号,「这台机器的转向队列一直读不出来」与「这台机器本来就没人 steer」在指标上完全同形。计数非零 = 去按日志里的 token 指纹查那一行。",
118
+ },
119
+ "server.sql-json.checkpoint.outcome-unreadable": {
120
+ cls: "F",
121
+ note: "S-550(`plugins/checkpoint-store-sql.ts` 的 `findDecidedApprovalsForBinding`,`/decide` 的**幂等回放**腿):一条已决 checkpoint 行的 `outcome` 这一格读不出形(手改 / 撕裂写 / 迁移脚本改过)⇒ 这一行交出 `decision: null`(= 判别不出),调用方按「整次拒绝」处置。放行的最坏后果 = 一次 decide **重试**拿不到上次的判词,退回按新请求处理(而不是回放)—— 回放是锦上添花的腿,让一条读不出的 JSON 把一次重试变成 500 比这更坏;方向上没有任何批准被放宽(判不出就是判不出,调用方拒绝)。故 F 类。**只容忍体的问题**:`not-text`(读腿漏了投影)与连接/超时一律原样上抛 —— 那是码坏或故障,不是数据坏。",
122
+ },
123
+ "server.sql-json.checkpoint.execution-outcome-unreadable": {
124
+ cls: "F",
125
+ note: "S-550(同上那条回放腿的第二格,`checkpoint.execution_outcome`,S-160 的执行判词):这一格读不出形 ⇒ 本行的 `executionOutcome` 交出 `null` = 「不知道这次批准有没有真的执行过」。放行的最坏后果 = 回放腿少一条「已执行」的证据,消费端据此**保守**处理(缺席在 core 的 `executionVerdict` 里读作 `unknown`,从来不读作「放行过」);不放行的代价与上一格同(一次 decide 重试 500)。⚠️ 同一格在 `get()` / `recordExecutionOutcome` 两条腿上**刻意相反**(抛,不容忍):那两条是结算记录的读写口,静默当缺席会让一份不同的记录覆盖已经在案的那份。tag 与上一格**分开计**:并计会让「哪一格在坏」在遥测里读不出来。",
126
+ },
127
+ "server.sql-json.task-run.usage-result-unreadable": {
128
+ cls: "F",
129
+ note: "S-550(`plugins/run-store-sql.ts` 的 `usageScan`,`/v1/usage/*` 的扫窗腿):窗内某一行的 `task_run.result` 这一格读不出形 ⇒ 这一行**计 tasks、不计 token/cost**(`stats` 键缺席),其余行照常。放行的最坏后果 = 用量总数偏低一行的量(`projectUsageStats` 的成文降级口径逐字如此,三个后端共用);不放行的代价是**一行坏 cell 打掉整个租户的用量页**(扫窗上限 50k 行)。⚠️ 同一列在 `getRun` / `listRuns` 两条腿上**刻意相反**(抛,不容忍):那两条腿上这一格是提交方要的**答案**,把它折成「跑完了没有结果」是在替一次不知道的读编一个答案;而这里它只是一个加数。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。",
130
+ },
115
131
  "server.retention.sweep-report-dropped": {
116
132
  cls: "F",
117
133
  note: "留存 sweep 的**失败上报**自己抛了(poison error 的 toString / 一条抛异常的 logger transport)⇒ 那一条 warn 与 stuck 指标发不出去。放行的最坏后果是**可观测性**损失而不是数据面损失:删除与审计的正确性完全在店事务里,本臂只影响运维看不看得见「这个域连败了」。不放行的后果反而更坏 —— 一个从上报里逃出去的异常会打断 per-domain 循环、饿死后面每一个域(reapers 的同款保护逐字同理由)。",
@@ -515,7 +515,7 @@ export declare function ensurePgApprovalAskSchema(q: PgQueryFn): Promise<void>;
515
515
  *
516
516
  * 病:本仓不发 `ALTER TABLE`(SCHEMA POLICY:改列就改 CREATE + 删库重建)。于是一台**没删表**就升上来
517
517
  * 的部署,`CREATE TABLE IF NOT EXISTS` 对存量 `approval_ask` 是空操作 —— 本车新加的
518
- * `rule_command` / `rule_scope_root` 两列不在,而 `ensureAsk` 的 INSERT 与 `ASK_COLS` 的每一条读
518
+ * `rule_command` / `rule_scope_root` 两列不在,而 `ensureAsk` 的 INSERT 与 `askCols` 的每一条读
519
519
  * **无条件**引用它们。后果不是「少一个新功能」,是**整条流内审批协议**在那台机器上是坏的:
520
520
  * · 每一只 ask 的 `ensureAsk` 撞 unknown column ⇒ 被 `noteStoreError` 吞掉 ⇒ 整只 ask 退回 park 路由
521
521
  * (卡不落库、`GET /v1/approvals` 的重放面恒空、迟到决议腿恒 404);
@@ -1,6 +1,7 @@
1
1
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
2
2
  import { canAskTransition } from "../approval-ask-machine.js";
3
3
  import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
4
+ import { decodeJsonColumn, foldJsonColumn, jsonColumnText, readAnyJsonValueColumn, readJsonObjectColumn } from "./sql-json-column.js";
4
5
  import { ensureIndexes } from "./ensure-index.js";
5
6
  export function assertIntegralEpochMs(field, value) {
6
7
  if (!Number.isSafeInteger(value)) {
@@ -211,7 +212,7 @@ export async function ensurePgApprovalAskSchema(q) {
211
212
  }
212
213
  export async function assertApprovalAskRuleMaterialSchema(query, dialect) {
213
214
  try {
214
- await query(`SELECT rule_command, rule_scope_root, updated_input FROM ${APPROVAL_ASK_TABLE} WHERE 1=0`);
215
+ await query(`SELECT rule_command, rule_scope_root, ${jsonColumnText(dialect, "updated_input")} FROM ${APPROVAL_ASK_TABLE} WHERE 1=0`);
215
216
  }
216
217
  catch (err) {
217
218
  if (!isMissingColumnError(err, dialect))
@@ -231,14 +232,21 @@ export async function assertApprovalAskRuleMaterialSchema(query, dialect) {
231
232
  `Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
232
233
  }
233
234
  }
234
- const ASK_COLS = "ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, " +
235
- "provisional, rev, decision, decision_actor, decision_note, settled_by, updated_input, decided_at_ms, denied_reason, gate_token, " +
236
- "gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, " +
237
- "created_at_ms, updated_at_ms";
238
- const BATCH_COLS = "batch_id, task_id, state, bound_ask_id, rev, created_at_ms, updated_at_ms";
239
- function parseJsonColumn(v) {
240
- return JSON.parse(String(v));
235
+ function askCols(dialect) {
236
+ return [
237
+ "ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state",
238
+ "provisional, rev, decision",
239
+ jsonColumnText(dialect, "decision_actor"),
240
+ "decision_note, settled_by",
241
+ jsonColumnText(dialect, "updated_input"),
242
+ "decided_at_ms, denied_reason, gate_token",
243
+ "gate_bound_call_id, gate_bound_input_hash, idempotency_key",
244
+ jsonColumnText(dialect, "card_json"),
245
+ "rule_command, rule_scope_root, schema_version, expires_at_ms",
246
+ "created_at_ms, updated_at_ms",
247
+ ].join(", ");
241
248
  }
249
+ const BATCH_COLS = "batch_id, task_id, state, bound_ask_id, rev, created_at_ms, updated_at_ms";
242
250
  function toBool(v) {
243
251
  return Boolean(Number(v));
244
252
  }
@@ -249,6 +257,15 @@ function strOrNull(v) {
249
257
  return v === null || v === undefined ? null : String(v);
250
258
  }
251
259
  function mapAskRow(r) {
260
+ const askId = String(r.ask_id);
261
+ try {
262
+ return mapAskRowBody(r, askId);
263
+ }
264
+ catch (e) {
265
+ throw foldJsonColumn(e, (m) => new Error(`approval-ask-store: ${m} — refusing rather than serve an ask row nobody can read`));
266
+ }
267
+ }
268
+ function mapAskRowBody(r, askId) {
252
269
  return {
253
270
  askId: String(r.ask_id),
254
271
  taskId: String(r.task_id),
@@ -264,17 +281,17 @@ function mapAskRow(r) {
264
281
  provisional: toBool(r.provisional),
265
282
  rev: Number(r.rev),
266
283
  decision: strOrNull(r.decision) ?? null,
267
- decisionActor: r.decision_actor == null ? null : parseJsonColumn(r.decision_actor),
284
+ decisionActor: r.decision_actor == null ? null : decodeJsonColumn(r.decision_actor, readJsonObjectColumn, `${APPROVAL_ASK_TABLE}.decision_actor for ask ${askId}`),
268
285
  decisionNote: strOrNull(r.decision_note),
269
286
  settledBy: strOrNull(r.settled_by),
270
- ...(r.updated_input == null ? {} : { updatedInput: parseJsonColumn(r.updated_input) }),
287
+ ...(r.updated_input == null ? {} : { updatedInput: decodeJsonColumn(r.updated_input, readAnyJsonValueColumn, `${APPROVAL_ASK_TABLE}.updated_input for ask ${askId}`) }),
271
288
  decidedAtMs: numOrNull(r.decided_at_ms),
272
289
  deniedReason: strOrNull(r.denied_reason),
273
290
  gateToken: strOrNull(r.gate_token),
274
291
  gateBoundCallId: strOrNull(r.gate_bound_call_id),
275
292
  gateBoundInputHash: strOrNull(r.gate_bound_input_hash),
276
293
  idempotencyKey: strOrNull(r.idempotency_key),
277
- cardJson: parseJsonColumn(r.card_json),
294
+ cardJson: decodeJsonColumn(r.card_json, readJsonObjectColumn, `${APPROVAL_ASK_TABLE}.card_json for ask ${askId}`),
278
295
  ruleCommand: strOrNull(r.rule_command),
279
296
  ruleScopeRoot: strOrNull(r.rule_scope_root),
280
297
  schemaVersion: Number(r.schema_version),
@@ -337,7 +354,7 @@ export class SqlApprovalAskStore {
337
354
  return isDupKeyError(this.db.dialect, e);
338
355
  }
339
356
  async getAskOn(conn, askId) {
340
- const { rows } = await conn.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ?`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = $1`), [askId]);
357
+ const { rows } = await conn.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ?`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = $1`), [askId]);
341
358
  return rows[0] ? mapAskRow(rows[0]) : null;
342
359
  }
343
360
  async getBatchOn(conn, batchId) {
@@ -485,7 +502,7 @@ export class SqlApprovalAskStore {
485
502
  }
486
503
  const askRes = await conn.query(`UPDATE ${APPROVAL_ASK_TABLE} SET state = ${ph(dialect, 1)}, rev = rev + 1, updated_at_ms = ${ph(dialect, 2)} WHERE ${pendingClaimWhere(dialect, 3)}`, [to, now, askId, batchId]);
487
504
  if (askRes.affected !== 1) {
488
- const sel = await conn.query(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ${ph(dialect, 1)} AND batch_id = ${ph(dialect, 2)} FOR UPDATE`, [askId, batchId]);
505
+ const sel = await conn.query(`SELECT ${askCols(dialect)} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ${ph(dialect, 1)} AND batch_id = ${ph(dialect, 2)} FOR UPDATE`, [askId, batchId]);
489
506
  await conn.rollback();
490
507
  return { claimed: false, current: projectAskTerminalClaimTruth(sel.rows[0] ? mapAskRow(sel.rows[0]) : null) };
491
508
  }
@@ -614,32 +631,32 @@ export class SqlApprovalAskStore {
614
631
  }
615
632
  }
616
633
  async listByState(state, limit) {
617
- const { rows } = await this.db.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE state = ? ORDER BY updated_at_ms ASC, created_at_ms ASC LIMIT ?`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE state = $1 ORDER BY updated_at_ms ASC, created_at_ms ASC LIMIT $2`), [state, limit]);
634
+ const { rows } = await this.db.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE state = ? ORDER BY updated_at_ms ASC, created_at_ms ASC LIMIT ?`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE state = $1 ORDER BY updated_at_ms ASC, created_at_ms ASC LIMIT $2`), [state, limit]);
618
635
  return rows.map(mapAskRow);
619
636
  }
620
637
  async listPendingByTask(taskId, signal) {
621
638
  if (signal?.aborted === true)
622
639
  throw abortedReadError("listPendingByTask");
623
- const { rows } = await withReadSignal(this.db.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = ? AND state = 'STREAM_PENDING' ORDER BY created_at_ms ASC`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = $1 AND state = 'STREAM_PENDING' ORDER BY created_at_ms ASC`), [taskId]), signal, "listPendingByTask");
640
+ const { rows } = await withReadSignal(this.db.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = ? AND state = 'STREAM_PENDING' ORDER BY created_at_ms ASC`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = $1 AND state = 'STREAM_PENDING' ORDER BY created_at_ms ASC`), [taskId]), signal, "listPendingByTask");
624
641
  return rows.map(mapAskRow);
625
642
  }
626
643
  async listPendingBySession(sessionId, owner, signal) {
627
644
  if (signal?.aborted === true)
628
645
  throw abortedReadError("listPendingBySession");
629
646
  const { rows } = await withReadSignal(this.db.query(owner === null
630
- ? this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = ? AND state = 'STREAM_PENDING' AND owner IS NULL ORDER BY created_at_ms ASC`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = $1 AND state = 'STREAM_PENDING' AND owner IS NULL ORDER BY created_at_ms ASC`)
631
- : this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = ? AND state = 'STREAM_PENDING' AND owner = ? ORDER BY created_at_ms ASC`, `SELECT ${ASK_COLS} 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");
647
+ ? this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = ? AND state = 'STREAM_PENDING' AND owner IS NULL ORDER BY created_at_ms ASC`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = $1 AND state = 'STREAM_PENDING' AND owner IS NULL ORDER BY created_at_ms ASC`)
648
+ : 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");
632
649
  return rows.map(mapAskRow);
633
650
  }
634
651
  async getAsk(askId) {
635
- const { rows } = await this.db.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ?`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = $1`), [
652
+ 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`), [
636
653
  askId,
637
654
  ]);
638
655
  return rows[0] ? mapAskRow(rows[0]) : null;
639
656
  }
640
657
  async getByIdempotencyKey(taskId, key) {
641
658
  assertIdempotencyKeyShape(key);
642
- const { rows } = await this.db.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = ? AND idempotency_key = ?`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = $1 AND idempotency_key = $2`), [taskId, key]);
659
+ const { rows } = await this.db.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = ? AND idempotency_key = ?`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = $1 AND idempotency_key = $2`), [taskId, key]);
643
660
  return rows[0] ? mapAskRow(rows[0]) : null;
644
661
  }
645
662
  async deferReconcile(askId, expectedState, expectedRev, nowMs) {
@@ -2,8 +2,30 @@ import { BackgroundAgentStoreError, STALE_RUNNING_REAP_ATTRIBUTION, } from "@sem
2
2
  import { pgSafeJsonStringify, pgSanitizeText } from "./pg-safe-json.js";
3
3
  import { isMysqlDupKeyError } from "./sql-errors.js";
4
4
  import { ensureIndexes } from "./ensure-index.js";
5
+ import { createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, jsonColumnText } from "./sql-json-column.js";
5
6
  export const BACKGROUND_AGENT_TABLE = "background_agent";
6
- const SUMMARY_COLS = "handle, owner, session_scoped, session_id, parent_session_id, root_session_id, name, agent_type, status, spawned_at_ms, updated_at_ms, settled_at_ms, usage_json";
7
+ function summaryCols(dialect) {
8
+ return ("handle, owner, session_scoped, session_id, parent_session_id, root_session_id, name, agent_type, status, " +
9
+ `spawned_at_ms, updated_at_ms, settled_at_ms, ${jsonColumnText(dialect, "usage_json")}`);
10
+ }
11
+ const readAgentRecordBody = createObjectColumnReader();
12
+ const readUsageBody = createObjectColumnReader();
13
+ function readRecordColumn(v, handle) {
14
+ try {
15
+ return decodeJsonColumn(v, readAgentRecordBody, `${BACKGROUND_AGENT_TABLE}.record_json for handle ${handle}`);
16
+ }
17
+ catch (e) {
18
+ throw foldJsonColumn(e, (m) => new Error(`BackgroundAgentStore: ${m} — refusing rather than serve a hollow agent record`));
19
+ }
20
+ }
21
+ function readUsageColumn(v, handle) {
22
+ try {
23
+ return decodeNullableJsonColumn(v, readUsageBody, `${BACKGROUND_AGENT_TABLE}.usage_json for handle ${handle}`);
24
+ }
25
+ catch (e) {
26
+ throw foldJsonColumn(e, (m) => new Error(`BackgroundAgentStore: ${m} — refusing rather than report a usage figure nobody can read`));
27
+ }
28
+ }
7
29
  const PROJ_COLS = "scope, owner, session_scoped, session_id, parent_session_id, root_session_id, session_anchor, name, agent_type, status, spawned_at_ms, updated_at_ms, settled_at_ms, usage_json, rev, parked_checkpoint_token, park_claim_id, admitted_org_scopes, admitted_org_write_scope, record_json";
8
30
  const PROJ_N = PROJ_COLS.split(", ").length;
9
31
  function assertScope(record) {
@@ -52,9 +74,7 @@ function rowToSummary(r) {
52
74
  spawnedAt: Number(r.spawned_at_ms),
53
75
  updatedAt: Number(r.updated_at_ms),
54
76
  ...(r.settled_at_ms !== null && r.settled_at_ms !== undefined ? { settledAt: Number(r.settled_at_ms) } : {}),
55
- ...(r.usage_json !== null && r.usage_json !== undefined
56
- ? { usage: JSON.parse(String(r.usage_json)) }
57
- : {}),
77
+ ...((u) => (u === undefined ? {} : { usage: u }))(readUsageColumn(r.usage_json, String(r.handle))),
58
78
  };
59
79
  }
60
80
  function flippedForm(live, now) {
@@ -192,8 +212,8 @@ export class TiDBBackgroundAgentStore {
192
212
  }
193
213
  }
194
214
  async get(handle, scope) {
195
- const [rows] = (await this.pool.query(`SELECT record_json FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ? AND handle = ?`, [scope, handle]));
196
- return rows.length > 0 ? JSON.parse(String(rows[0].record_json)) : null;
215
+ const [rows] = (await this.pool.query(`SELECT ${jsonColumnText("tidb", "record_json")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ? AND handle = ?`, [scope, handle]));
216
+ return rows.length > 0 ? readRecordColumn(rows[0].record_json, handle) : null;
197
217
  }
198
218
  async update(handle, scope, record, expect) {
199
219
  const stored = storedForm(record, handle, scope, expect.rev + 1);
@@ -211,12 +231,12 @@ export class TiDBBackgroundAgentStore {
211
231
  return res.affectedRows === 1;
212
232
  }
213
233
  async listBySession(scope, sessionId, opts) {
214
- const [rows] = (await this.pool.query(`SELECT ${SUMMARY_COLS} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ? AND (session_anchor = ? OR root_session_id = ?)${opts?.status !== undefined ? " AND status = ?" : ""}
234
+ const [rows] = (await this.pool.query(`SELECT ${summaryCols("tidb")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ? AND (session_anchor = ? OR root_session_id = ?)${opts?.status !== undefined ? " AND status = ?" : ""}
215
235
  ORDER BY spawned_at_ms DESC, handle DESC${limitClause(opts?.limit)}`, opts?.status !== undefined ? [scope, sessionId, sessionId, opts.status] : [scope, sessionId, sessionId]));
216
236
  return rows.map(rowToSummary);
217
237
  }
218
238
  async listByScope(scope, opts) {
219
- const [rows] = (await this.pool.query(`SELECT ${SUMMARY_COLS} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ?${opts?.status !== undefined ? " AND status = ?" : ""}
239
+ const [rows] = (await this.pool.query(`SELECT ${summaryCols("tidb")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ?${opts?.status !== undefined ? " AND status = ?" : ""}
220
240
  ORDER BY spawned_at_ms DESC, handle DESC${limitClause(opts?.limit)}`, opts?.status !== undefined ? [scope, opts.status] : [scope]));
221
241
  return rows.map(rowToSummary);
222
242
  }
@@ -229,9 +249,9 @@ export class TiDBBackgroundAgentStore {
229
249
  return 0;
230
250
  const flipped = new Set();
231
251
  if (opts.staleRunningMaxAgeMs !== undefined) {
232
- const [stale] = (await this.pool.query(`SELECT handle, record_json FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ? AND status = 'running' AND updated_at_ms < ?`, [scope, now - opts.staleRunningMaxAgeMs]));
252
+ const [stale] = (await this.pool.query(`SELECT handle, ${jsonColumnText("tidb", "record_json")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ? AND status = 'running' AND updated_at_ms < ?`, [scope, now - opts.staleRunningMaxAgeMs]));
233
253
  for (const row of stale) {
234
- const live = JSON.parse(String(row.record_json));
254
+ const live = readRecordColumn(row.record_json, String(row.handle));
235
255
  if (await this.update(String(row.handle), scope, flippedForm(live, now), { rev: live.rev }))
236
256
  flipped.add(String(row.handle));
237
257
  }
@@ -272,8 +292,8 @@ export class PgBackgroundAgentStore {
272
292
  }
273
293
  }
274
294
  async get(handle, scope) {
275
- const { rows } = await this.q(`SELECT record_json FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1 AND handle = $2`, [scope, handle]);
276
- return rows.length > 0 ? JSON.parse(String(rows[0].record_json)) : null;
295
+ const { rows } = await this.q(`SELECT ${jsonColumnText("pg", "record_json")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1 AND handle = $2`, [scope, handle]);
296
+ return rows.length > 0 ? readRecordColumn(rows[0].record_json, handle) : null;
277
297
  }
278
298
  async update(handle, scope, record, expect) {
279
299
  const stored = storedForm(record, handle, scope, expect.rev + 1);
@@ -293,12 +313,12 @@ export class PgBackgroundAgentStore {
293
313
  return rows.length === 1;
294
314
  }
295
315
  async listBySession(scope, sessionId, opts) {
296
- const { rows } = await this.q(`SELECT ${SUMMARY_COLS} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1 AND (session_anchor = $2 OR root_session_id = $3)${opts?.status !== undefined ? " AND status = $4" : ""}
316
+ const { rows } = await this.q(`SELECT ${summaryCols("pg")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1 AND (session_anchor = $2 OR root_session_id = $3)${opts?.status !== undefined ? " AND status = $4" : ""}
297
317
  ORDER BY spawned_at_ms DESC, handle DESC${limitClause(opts?.limit)}`, opts?.status !== undefined ? [scope, sessionId, sessionId, opts.status] : [scope, sessionId, sessionId]);
298
318
  return rows.map(rowToSummary);
299
319
  }
300
320
  async listByScope(scope, opts) {
301
- const { rows } = await this.q(`SELECT ${SUMMARY_COLS} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1${opts?.status !== undefined ? " AND status = $2" : ""}
321
+ const { rows } = await this.q(`SELECT ${summaryCols("pg")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1${opts?.status !== undefined ? " AND status = $2" : ""}
302
322
  ORDER BY spawned_at_ms DESC, handle DESC${limitClause(opts?.limit)}`, opts?.status !== undefined ? [scope, opts.status] : [scope]);
303
323
  return rows.map(rowToSummary);
304
324
  }
@@ -311,9 +331,9 @@ export class PgBackgroundAgentStore {
311
331
  return 0;
312
332
  const affected = new Set();
313
333
  if (opts.staleRunningMaxAgeMs !== undefined) {
314
- const { rows: stale } = await this.q(`SELECT handle, record_json FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1 AND status = 'running' AND updated_at_ms < $2`, [scope, now - opts.staleRunningMaxAgeMs]);
334
+ const { rows: stale } = await this.q(`SELECT handle, ${jsonColumnText("pg", "record_json")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1 AND status = 'running' AND updated_at_ms < $2`, [scope, now - opts.staleRunningMaxAgeMs]);
315
335
  for (const row of stale) {
316
- const live = JSON.parse(String(row.record_json));
336
+ const live = readRecordColumn(row.record_json, String(row.handle));
317
337
  if (await this.update(String(row.handle), scope, flippedForm(live, now), { rev: live.rev }))
318
338
  affected.add(String(row.handle));
319
339
  }
@@ -381,8 +381,12 @@ export declare function approvalDecisionOfWinner(winner: ResolvedOutcome | undef
381
381
  * - `{ readable: true, toolCallId: "…" }` —— 读出来了,拿去比。
382
382
  *
383
383
  * 逐层 `typeof` 收窄,不做裸 `as` 断言:blob 是持久层读回来的 `unknown`,是信任边界(宪法 [ref])。
384
+ *
385
+ * 🔴 S-550:入参是**已经解过码的体**,不是一格列文本 —— 本函数不再自己 `JSON.parse`(SQL 车道的解码
386
+ * 唯一属主是 `sql-json-column.ts`,见 {@link pendingActionOfColumn};local 孪生本来就递一只对象进来)。
387
+ * 于是「串就再解一次」这条读法在这里按构造消失,两条车道递进来的东西同形。
384
388
  */
385
- export declare function pendingActionToolCallId(blob: unknown): {
389
+ export declare function pendingActionToolCallId(body: unknown): {
386
390
  readable: true;
387
391
  toolCallId: string | null;
388
392
  boundInputHash: string | null;