@sema-agent/server 7.93.5 → 7.93.7
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.
- package/USAGE.md +8 -0
- package/dist/bake-runner/main.d.ts +28 -1
- package/dist/bake-runner/main.js +122 -38
- package/dist/bake-runner/runner.d.ts +85 -7
- package/dist/bake-runner/runner.js +116 -27
- package/dist/config-catalog.js +1 -1
- package/dist/hooks/hook-runner.js +5 -1
- package/dist/observability/fail-open.d.ts +16 -0
- package/dist/observability/fail-open.js +16 -0
- package/dist/plugins/background-agent-store-sql.js +36 -16
- package/dist/plugins/checkpoint-store-sql.d.ts +5 -1
- package/dist/plugins/checkpoint-store-sql.js +111 -66
- package/dist/plugins/host-platform.d.ts +66 -4
- package/dist/plugins/host-platform.js +14 -3
- package/dist/plugins/leader-run-store-sql.js +16 -6
- package/dist/plugins/remote-env-host.js +9 -21
- package/dist/plugins/run-store-sql.d.ts +1 -1
- package/dist/plugins/run-store-sql.js +41 -10
- package/dist/plugins/session-policy-store-sql.js +5 -1
- package/dist/plugins/sql-json-column.d.ts +49 -0
- package/dist/plugins/sql-json-column.js +10 -0
- package/dist/plugins/workflow-run-store-sql.js +14 -4
- package/package.json +1 -1
|
@@ -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 的同款保护逐字同理由)。",
|
|
@@ -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
|
-
|
|
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
|
-
...(
|
|
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 ?
|
|
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 ${
|
|
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 ${
|
|
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 =
|
|
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 ?
|
|
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 ${
|
|
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 ${
|
|
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 =
|
|
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(
|
|
389
|
+
export declare function pendingActionToolCallId(body: unknown): {
|
|
386
390
|
readable: true;
|
|
387
391
|
toolCallId: string | null;
|
|
388
392
|
boundInputHash: string | null;
|
|
@@ -4,7 +4,7 @@ import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
|
|
|
4
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
|
-
import {
|
|
7
|
+
import { createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText, readAnyJsonValueColumn, readJsonObjectColumn, readNonNullJsonValueColumn, } from "./sql-json-column.js";
|
|
8
8
|
import { recordFailOpen } from "../observability/fail-open.js";
|
|
9
9
|
import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
10
10
|
import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
|
|
@@ -113,22 +113,61 @@ export function boundedRuleOffers(raw, opts = {}) {
|
|
|
113
113
|
}
|
|
114
114
|
return out.length > 0 ? out : undefined;
|
|
115
115
|
}
|
|
116
|
+
const readCheckpointBlobBody = createObjectColumnReader();
|
|
117
|
+
const readResumeOutcomeBody = createObjectColumnReader();
|
|
118
|
+
const readGateOutcomeBody = createObjectColumnReader();
|
|
119
|
+
const readRiskDescriptorBody = createObjectColumnReader();
|
|
120
|
+
function readJsonArrayBody(body) {
|
|
121
|
+
return Array.isArray(body) ? { value: body } : { problem: "expected a JSON array" };
|
|
122
|
+
}
|
|
123
|
+
function decodeCheckpointColumn(cell, where) {
|
|
124
|
+
return decodeJsonColumn(cell, readCheckpointBlobBody, `checkpoint.checkpoint ${where}`);
|
|
125
|
+
}
|
|
126
|
+
function decodeOutcomeColumn(cell, where) {
|
|
127
|
+
return decodeNullableJsonColumn(cell, readResumeOutcomeBody, `checkpoint.outcome ${where}`);
|
|
128
|
+
}
|
|
129
|
+
function decodeExecutionOutcomeColumn(cell, where) {
|
|
130
|
+
return decodeNullableJsonColumn(cell, readGateOutcomeBody, `checkpoint.execution_outcome ${where}`);
|
|
131
|
+
}
|
|
132
|
+
function decodeToolInputColumn(cell) {
|
|
133
|
+
return decodeNullableJsonColumn(cell, readNonNullJsonValueColumn, "checkpoint.tool_input (operator queue row)");
|
|
134
|
+
}
|
|
135
|
+
function decodeCtxColumn(cell, where) {
|
|
136
|
+
const body = decodeJsonColumn(cell, readAnyJsonValueColumn, `checkpoint_ctx.ctx ${where}`);
|
|
137
|
+
return body;
|
|
138
|
+
}
|
|
139
|
+
function refusing(what, read) {
|
|
140
|
+
try {
|
|
141
|
+
return read();
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
throw foldJsonColumn(e, (m) => new Error(`SqlCheckpointStore: ${m} — refusing rather than serve ${what}`));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
116
147
|
function readRuleOffersCell(cell) {
|
|
148
|
+
if (cell === null)
|
|
149
|
+
return undefined;
|
|
117
150
|
let parsed;
|
|
118
151
|
try {
|
|
119
|
-
parsed =
|
|
152
|
+
parsed = decodeJsonColumn(cell, readJsonArrayBody, "checkpoint.rule_suggestions (operator queue row)");
|
|
120
153
|
}
|
|
121
|
-
catch {
|
|
154
|
+
catch (e) {
|
|
155
|
+
if (!isJsonColumnBodyProblem(e))
|
|
156
|
+
throw e;
|
|
122
157
|
recordFailOpen("server.approvals.rule-suggestions-cell-unreadable");
|
|
123
158
|
return undefined;
|
|
124
159
|
}
|
|
125
160
|
return boundedRuleOffers(parsed, { countRedactions: false });
|
|
126
161
|
}
|
|
127
162
|
function readRiskDescriptorCell(cell) {
|
|
163
|
+
if (cell === null)
|
|
164
|
+
return null;
|
|
128
165
|
try {
|
|
129
|
-
return
|
|
166
|
+
return decodeJsonColumn(cell, readRiskDescriptorBody, "checkpoint.risk_descriptor (operator queue row)");
|
|
130
167
|
}
|
|
131
|
-
catch {
|
|
168
|
+
catch (e) {
|
|
169
|
+
if (!isJsonColumnBodyProblem(e))
|
|
170
|
+
throw e;
|
|
132
171
|
recordFailOpen("server.approvals.risk-descriptor-cell-unreadable");
|
|
133
172
|
return null;
|
|
134
173
|
}
|
|
@@ -166,23 +205,16 @@ export function approvalDecisionOfWinner(winner, boundCallId) {
|
|
|
166
205
|
}
|
|
167
206
|
}
|
|
168
207
|
}
|
|
169
|
-
export function pendingActionToolCallId(
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
parsed = typeof blob === "string" ? JSON.parse(blob) : blob;
|
|
173
|
-
}
|
|
174
|
-
catch {
|
|
175
|
-
return { readable: false };
|
|
176
|
-
}
|
|
177
|
-
if (typeof parsed !== "object" || parsed === null)
|
|
208
|
+
export function pendingActionToolCallId(body) {
|
|
209
|
+
if (typeof body !== "object" || body === null)
|
|
178
210
|
return { readable: false };
|
|
179
|
-
const rawSource = "sourceTaskId" in
|
|
211
|
+
const rawSource = "sourceTaskId" in body ? body.sourceTaskId : null;
|
|
180
212
|
if (rawSource !== null && rawSource !== undefined && typeof rawSource !== "string")
|
|
181
213
|
return { readable: false };
|
|
182
214
|
const sourceTaskId = typeof rawSource === "string" ? rawSource : null;
|
|
183
|
-
if (!("pendingAction" in
|
|
215
|
+
if (!("pendingAction" in body))
|
|
184
216
|
return { readable: true, toolCallId: null, boundInputHash: null, kind: null, sourceTaskId };
|
|
185
|
-
const pa =
|
|
217
|
+
const pa = body.pendingAction;
|
|
186
218
|
if (typeof pa !== "object" || pa === null)
|
|
187
219
|
return { readable: false };
|
|
188
220
|
const kind = "kind" in pa ? pa.kind : null;
|
|
@@ -200,6 +232,18 @@ export function pendingActionToolCallId(blob) {
|
|
|
200
232
|
return { readable: false };
|
|
201
233
|
return { readable: true, toolCallId: id, boundInputHash: bih, kind, sourceTaskId };
|
|
202
234
|
}
|
|
235
|
+
function pendingActionOfColumn(cell, where) {
|
|
236
|
+
let body;
|
|
237
|
+
try {
|
|
238
|
+
body = decodeJsonColumn(cell, readJsonObjectColumn, `checkpoint.checkpoint ${where}`);
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
if (!isJsonColumnBodyProblem(e))
|
|
242
|
+
throw e;
|
|
243
|
+
return { readable: false };
|
|
244
|
+
}
|
|
245
|
+
return pendingActionToolCallId(body);
|
|
246
|
+
}
|
|
203
247
|
function readActorAssertion(v) {
|
|
204
248
|
if (typeof v !== "object" || v === null)
|
|
205
249
|
return undefined;
|
|
@@ -236,33 +280,24 @@ function readQueueEntry(v) {
|
|
|
236
280
|
}
|
|
237
281
|
export function readPendingSteerQueueColumn(queueRaw) {
|
|
238
282
|
let lossy = false;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
lossy = true;
|
|
249
|
-
return undefined;
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
const queueParsed = parse(queueRaw);
|
|
283
|
+
let items;
|
|
284
|
+
try {
|
|
285
|
+
items = decodeNullableJsonColumn(queueRaw, readJsonArrayBody, "checkpoint.pending_steer_queue");
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
if (!isJsonColumnBodyProblem(e))
|
|
289
|
+
throw e;
|
|
290
|
+
lossy = true;
|
|
291
|
+
}
|
|
253
292
|
let queue;
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
lossy = true;
|
|
263
|
-
else
|
|
264
|
-
queue.push(entry);
|
|
265
|
-
}
|
|
293
|
+
if (items !== undefined) {
|
|
294
|
+
queue = [];
|
|
295
|
+
for (const item of items) {
|
|
296
|
+
const entry = readQueueEntry(item);
|
|
297
|
+
if (entry === undefined)
|
|
298
|
+
lossy = true;
|
|
299
|
+
else
|
|
300
|
+
queue.push(entry);
|
|
266
301
|
}
|
|
267
302
|
}
|
|
268
303
|
return { state: { ...(queue !== undefined ? { pendingSteerQueue: queue } : {}) }, lossy };
|
|
@@ -359,7 +394,9 @@ export class SqlCheckpointStore {
|
|
|
359
394
|
}
|
|
360
395
|
}
|
|
361
396
|
async get(token) {
|
|
362
|
-
const { rows } = await this.db.query(this.q(
|
|
397
|
+
const { rows } = await this.db.query(this.q(`SELECT version, status, ${jsonColumnText("tidb", "checkpoint")}, ${jsonColumnText("tidb", "outcome")}, reopen_reason, rev, ` +
|
|
398
|
+
`${jsonColumnText("tidb", "pending_steer_queue")}, ${jsonColumnText("tidb", "execution_outcome")}, execution_at_ms FROM checkpoint WHERE token = ?`, `SELECT version, status, ${jsonColumnText("pg", "checkpoint")}, ${jsonColumnText("pg", "outcome")}, reopen_reason, rev, ` +
|
|
399
|
+
`${jsonColumnText("pg", "pending_steer_queue")}, ${jsonColumnText("pg", "execution_outcome")}, execution_at_ms FROM checkpoint WHERE token = $1`), [token]);
|
|
363
400
|
const r = rows[0];
|
|
364
401
|
if (!r)
|
|
365
402
|
return null;
|
|
@@ -367,21 +404,22 @@ export class SqlCheckpointStore {
|
|
|
367
404
|
if (version > MAX_SUPPORTED_CHECKPOINT_VERSION) {
|
|
368
405
|
throw new Error(`checkpoint format version ${version} is newer than this build supports (max ${MAX_SUPPORTED_CHECKPOINT_VERSION}) — upgrade the service to resume it`);
|
|
369
406
|
}
|
|
370
|
-
const cp =
|
|
407
|
+
const cp = refusing("a checkpoint nobody can resume", () => decodeCheckpointColumn(r.checkpoint, `for token ${tokenFingerprint(token)}`));
|
|
371
408
|
cp.status = r.status;
|
|
372
|
-
const outcomeRaw =
|
|
409
|
+
const outcomeRaw = refusing("a decision record nobody can read", () => decodeOutcomeColumn(r.outcome, `for token ${tokenFingerprint(token)}`));
|
|
373
410
|
if (outcomeRaw)
|
|
374
411
|
cp.resolvedOutcome = winnerFromOutcome(outcomeRaw);
|
|
375
412
|
cp.reopenReason = r.reopen_reason ?? undefined;
|
|
376
413
|
cp.rev = r.rev == null ? 0 : Number(r.rev);
|
|
377
|
-
const executionOutcome =
|
|
378
|
-
if (executionOutcome !==
|
|
414
|
+
const executionOutcome = refusing("an execution record nobody can read", () => decodeExecutionOutcomeColumn(r.execution_outcome, `for token ${tokenFingerprint(token)}`));
|
|
415
|
+
if (executionOutcome !== undefined) {
|
|
379
416
|
cp.executionOutcome = executionOutcome;
|
|
380
417
|
if (r.execution_at_ms != null)
|
|
381
418
|
cp.executionAtMs = Number(r.execution_at_ms);
|
|
382
419
|
}
|
|
383
420
|
const steer = readPendingSteerQueueColumn(r.pending_steer_queue);
|
|
384
421
|
if (steer.lossy) {
|
|
422
|
+
recordFailOpen("server.sql-json.checkpoint.pending-steer-queue-lossy");
|
|
385
423
|
this.logger?.info?.("checkpoint_pending_steer_unreadable", { tokenFp: tokenFingerprint(token) });
|
|
386
424
|
}
|
|
387
425
|
if (cp.state) {
|
|
@@ -412,14 +450,14 @@ export class SqlCheckpointStore {
|
|
|
412
450
|
}
|
|
413
451
|
async recordExecutionOutcome(token, scope, gate) {
|
|
414
452
|
for (let attempt = 0; attempt < EXECUTION_RECORD_CAS_ATTEMPTS; attempt++) {
|
|
415
|
-
const { rows } = await this.db.query(this.q(
|
|
453
|
+
const { rows } = await this.db.query(this.q(`SELECT scope, status, ${jsonColumnText("tidb", "execution_outcome")} FROM checkpoint WHERE token = ?`, `SELECT scope, status, ${jsonColumnText("pg", "execution_outcome")} FROM checkpoint WHERE token = $1`), [token]);
|
|
416
454
|
const row = rows[0];
|
|
417
455
|
const facts = row === undefined
|
|
418
456
|
? undefined
|
|
419
457
|
: {
|
|
420
458
|
scope: String(row.scope),
|
|
421
459
|
status: row.status,
|
|
422
|
-
...((v) => (v ===
|
|
460
|
+
...((v) => (v === undefined ? {} : { executionOutcome: v }))(refusing("an execution record nobody can read", () => decodeExecutionOutcomeColumn(row.execution_outcome, `for token ${tokenFingerprint(token)}`))),
|
|
423
461
|
};
|
|
424
462
|
const disposition = executionRecordDisposition(facts, scope, gate);
|
|
425
463
|
if (disposition === "conflict")
|
|
@@ -436,7 +474,7 @@ export class SqlCheckpointStore {
|
|
|
436
474
|
async setPendingSteer(token, scope, steer) {
|
|
437
475
|
const clean = validatePendingSteer(steer);
|
|
438
476
|
for (let attempt = 0; attempt < STEER_CAS_ATTEMPTS; attempt++) {
|
|
439
|
-
const { rows } = await this.db.query(this.q(
|
|
477
|
+
const { rows } = await this.db.query(this.q(`SELECT status, ${jsonColumnText("tidb", "pending_steer_queue")}, pending_steer_rev FROM checkpoint WHERE token = ? AND scope = ?`, `SELECT status, ${jsonColumnText("pg", "pending_steer_queue")}, pending_steer_rev FROM checkpoint WHERE token = $1 AND scope = $2`), [token, scope]);
|
|
440
478
|
const row = rows[0];
|
|
441
479
|
if (!row || row.status !== "pending")
|
|
442
480
|
return false;
|
|
@@ -480,7 +518,9 @@ export class SqlCheckpointStore {
|
|
|
480
518
|
return rows.map((r) => ({ sessionId: String(r.session_id), scope: String(r.scope) }));
|
|
481
519
|
}
|
|
482
520
|
async listPending(scope) {
|
|
483
|
-
const
|
|
521
|
+
const d = this.db.dialect;
|
|
522
|
+
const base = `SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, ${jsonColumnText(d, "tool_input")}, c.bound_input_hash, ` +
|
|
523
|
+
`${jsonColumnText(d, "risk_descriptor")}, ${jsonColumnText(d, "rule_suggestions")}, c.has_bidi_controls, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id ` +
|
|
484
524
|
"FROM checkpoint c LEFT JOIN task_active ta ON ta.session_id = c.session_id WHERE c.status='pending'";
|
|
485
525
|
const { rows } = scope
|
|
486
526
|
? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at_ms ASC`, [scope])
|
|
@@ -500,7 +540,7 @@ export class SqlCheckpointStore {
|
|
|
500
540
|
...(boundInputHash !== null ? { boundInputHash } : {}),
|
|
501
541
|
...(gateKind !== null ? { gateKind } : {}),
|
|
502
542
|
taskId: r.task_id ?? null,
|
|
503
|
-
input: r.tool_input ?? null,
|
|
543
|
+
input: refusing("a tool input the operator would be approving unseen", () => decodeToolInputColumn(r.tool_input)) ?? null,
|
|
504
544
|
createdAt: Number(r.created_at_ms),
|
|
505
545
|
deadline: r.deadline == null ? null : Number(r.deadline),
|
|
506
546
|
riskDescriptor: readRiskDescriptorCell(r.risk_descriptor),
|
|
@@ -512,12 +552,10 @@ export class SqlCheckpointStore {
|
|
|
512
552
|
return out.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
|
|
513
553
|
}
|
|
514
554
|
async listByScope(scope) {
|
|
515
|
-
const { rows } = await this.db.query(this.q(
|
|
555
|
+
const { rows } = await this.db.query(this.q(`SELECT ${jsonColumnText("tidb", "checkpoint")}, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at_ms ASC LIMIT 500`, `SELECT ${jsonColumnText("pg", "checkpoint")}, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at_ms ASC LIMIT 500`), [scope]);
|
|
516
556
|
const out = [];
|
|
517
557
|
for (const r of rows) {
|
|
518
|
-
const cp =
|
|
519
|
-
if (!cp)
|
|
520
|
-
continue;
|
|
558
|
+
const cp = refusing("a checkpoint nobody can resume", () => decodeCheckpointColumn(r.checkpoint, `in the ${scope} scheduler view`));
|
|
521
559
|
cp.status = r.status;
|
|
522
560
|
out.push(summarizeCheckpoint(cp));
|
|
523
561
|
}
|
|
@@ -529,8 +567,8 @@ export class SqlCheckpointStore {
|
|
|
529
567
|
return rows[0] ? String(rows[0].token) : null;
|
|
530
568
|
}
|
|
531
569
|
async findCheckpointCandidatesForAsk(scope, sessionId, toolCallId, sinceMs) {
|
|
532
|
-
const { rows } = await this.db.query(this.q(
|
|
533
|
-
"WHERE scope=? AND session_id=? AND created_at_ms>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at_ms ASC",
|
|
570
|
+
const { rows } = await this.db.query(this.q(`SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, ${jsonColumnText("tidb", "checkpoint")} FROM checkpoint ` +
|
|
571
|
+
"WHERE scope=? AND session_id=? AND created_at_ms>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at_ms ASC", `SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, ${jsonColumnText("pg", "checkpoint")} FROM checkpoint ` +
|
|
534
572
|
"WHERE scope=$1 AND session_id=$2 AND created_at_ms>=$3 AND (tool_call_id=$4 OR tool_call_id IS NULL) ORDER BY created_at_ms ASC"), [scope, sessionId, sinceMs, toolCallId]);
|
|
535
573
|
const out = [];
|
|
536
574
|
for (const r of rows) {
|
|
@@ -545,7 +583,7 @@ export class SqlCheckpointStore {
|
|
|
545
583
|
out.push({ ...base, boundCallId: null, unparseable: true });
|
|
546
584
|
continue;
|
|
547
585
|
}
|
|
548
|
-
const derived =
|
|
586
|
+
const derived = pendingActionOfColumn(r.checkpoint, `for token ${tokenFingerprint(String(r.token))}`);
|
|
549
587
|
if (!derived.readable) {
|
|
550
588
|
out.push({ ...base, boundCallId: null, unparseable: true });
|
|
551
589
|
continue;
|
|
@@ -567,24 +605,31 @@ export class SqlCheckpointStore {
|
|
|
567
605
|
return out;
|
|
568
606
|
}
|
|
569
607
|
async findDecidedApprovalsForBinding(sessionId, boundCallId) {
|
|
570
|
-
const { rows } = await this.db.query(this.q(
|
|
571
|
-
`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}`,
|
|
608
|
+
const { rows } = await this.db.query(this.q(`SELECT scope, bound_input_hash, ${jsonColumnText("tidb", "outcome")}, ${jsonColumnText("tidb", "execution_outcome")} FROM checkpoint ` +
|
|
609
|
+
`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, ${jsonColumnText("pg", "outcome")}, ${jsonColumnText("pg", "execution_outcome")} FROM checkpoint ` +
|
|
572
610
|
`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]);
|
|
573
611
|
return rows.map((r) => {
|
|
574
612
|
let winner;
|
|
575
613
|
try {
|
|
576
|
-
const outcome =
|
|
614
|
+
const outcome = decodeOutcomeColumn(r.outcome, `for the decided row of session ${sessionId}`);
|
|
577
615
|
winner = outcome ? winnerFromOutcome(outcome) : undefined;
|
|
578
616
|
}
|
|
579
617
|
catch (e) {
|
|
618
|
+
if (!isJsonColumnBodyProblem(e))
|
|
619
|
+
throw e;
|
|
620
|
+
recordFailOpen("server.sql-json.checkpoint.outcome-unreadable");
|
|
580
621
|
this.logger?.info?.("checkpoint_decided_outcome_unreadable", { sessionId, error: e instanceof Error ? e.message : String(e) });
|
|
581
622
|
}
|
|
582
623
|
let executed = null;
|
|
583
624
|
try {
|
|
584
|
-
const
|
|
625
|
+
const cell = decodeExecutionOutcomeColumn(r.execution_outcome, `for the decided row of session ${sessionId}`);
|
|
626
|
+
const verdict = executionVerdict({ ...((v) => (v === undefined ? {} : { executionOutcome: v }))(cell) });
|
|
585
627
|
executed = verdict.kind === "executed" ? verdict.gate : null;
|
|
586
628
|
}
|
|
587
629
|
catch (e) {
|
|
630
|
+
if (!isJsonColumnBodyProblem(e))
|
|
631
|
+
throw e;
|
|
632
|
+
recordFailOpen("server.sql-json.checkpoint.execution-outcome-unreadable");
|
|
588
633
|
this.logger?.info?.("checkpoint_execution_outcome_unreadable", { sessionId, error: e instanceof Error ? e.message : String(e) });
|
|
589
634
|
}
|
|
590
635
|
return {
|
|
@@ -615,8 +660,8 @@ export class SqlCheckpointStore {
|
|
|
615
660
|
"ON CONFLICT (session_id) DO UPDATE SET ctx = EXCLUDED.ctx, updated_at_ms = EXCLUDED.updated_at_ms"), [sessionId, this.json(ctx, "checkpoint ctx"), Date.now()]);
|
|
616
661
|
}
|
|
617
662
|
async getCtx(sessionId) {
|
|
618
|
-
const { rows } = await this.db.query(this.q(
|
|
619
|
-
return rows[0] ?
|
|
663
|
+
const { rows } = await this.db.query(this.q(`SELECT ${jsonColumnText("tidb", "ctx")} FROM checkpoint_ctx WHERE session_id=?`, `SELECT ${jsonColumnText("pg", "ctx")} FROM checkpoint_ctx WHERE session_id=$1`), [sessionId]);
|
|
664
|
+
return rows[0] ? refusing("a resume context nobody can read", () => decodeCtxColumn(rows[0].ctx, `for session ${JSON.stringify(sessionId)}`)) : null;
|
|
620
665
|
}
|
|
621
666
|
async reapCtx(cutoff) {
|
|
622
667
|
const res = await this.db.query(this.q("DELETE FROM checkpoint_ctx WHERE updated_at_ms < ? " +
|