@sema-agent/server 7.93.5 → 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.
@@ -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
- 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;
@@ -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 { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
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 = parseJson(cell);
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 parseJson(cell);
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(blob) {
170
- let parsed;
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 parsed ? parsed.sourceTaskId : null;
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 parsed))
215
+ if (!("pendingAction" in body))
184
216
  return { readable: true, toolCallId: null, boundInputHash: null, kind: null, sourceTaskId };
185
- const pa = parsed.pendingAction;
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
- const parse = (raw) => {
240
- if (raw === null || raw === undefined)
241
- return undefined;
242
- if (typeof raw !== "string")
243
- return raw;
244
- try {
245
- return JSON.parse(raw);
246
- }
247
- catch {
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 (queueParsed !== undefined) {
255
- if (!Array.isArray(queueParsed))
256
- lossy = true;
257
- else {
258
- queue = [];
259
- for (const item of queueParsed) {
260
- const entry = readQueueEntry(item);
261
- if (entry === undefined)
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("SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer_queue, execution_outcome, execution_at_ms FROM checkpoint WHERE token = ?", "SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer_queue, execution_outcome, execution_at_ms FROM checkpoint WHERE token = $1"), [token]);
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 = parseJson(r.checkpoint);
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 = parseJson(r.outcome);
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 = parseJson(r.execution_outcome);
378
- if (executionOutcome !== null) {
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("SELECT scope, status, execution_outcome FROM checkpoint WHERE token = ?", "SELECT scope, status, execution_outcome FROM checkpoint WHERE token = $1"), [token]);
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 === null ? {} : { executionOutcome: v }))(parseJson(row.execution_outcome)),
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("SELECT status, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = ? AND scope = ?", "SELECT status, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = $1 AND scope = $2"), [token, scope]);
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 base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.rule_suggestions, c.has_bidi_controls, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id " +
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("SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at_ms ASC LIMIT 500", "SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at_ms ASC LIMIT 500"), [scope]);
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 = parseJson(r.checkpoint);
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("SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
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", "SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
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 = pendingActionToolCallId(r.checkpoint);
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("SELECT scope, bound_input_hash, outcome, execution_outcome FROM checkpoint " +
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}`, "SELECT scope, bound_input_hash, outcome, execution_outcome FROM checkpoint " +
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 = parseJson(r.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 verdict = executionVerdict({ ...((v) => (v === null ? {} : { executionOutcome: v }))(parseJson(r.execution_outcome)) });
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("SELECT ctx FROM checkpoint_ctx WHERE session_id=?", "SELECT ctx FROM checkpoint_ctx WHERE session_id=$1"), [sessionId]);
619
- return rows[0] ? parseJson(rows[0].ctx) : null;
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 < ? " +
@@ -1,3 +1,28 @@
1
+ /**
2
+ * Host-lane PLATFORM seam (DESIGN-windows-native.md, FINAL r3) — the ONE place the host exec adapter's
3
+ * POSIX/win32 differences live, so `remote-env-host.ts` stays a single code path with platform-gated leaves.
4
+ *
5
+ * 🔴 Iron invariant (design §4.1): the POSIX path is BYTE-IDENTICAL to the pre-Windows code — every helper is
6
+ * `win32 ? new : exactly-what-the-inline-code-did` (same syscall, same throw behavior). Never "improve" POSIX
7
+ * here; the existing full test suite is the regression net.
8
+ *
9
+ * win32 semantics (design D1-D4):
10
+ * - shell = Git Bash via core 1.224 `getShellConfig` (WSL-launcher-filtered — the `System32\bash.exe`
11
+ * trap). Fail-LOUD when absent; never silently degrade to cmd (D1).
12
+ * - kill = core 1.224 `signalProcessTree` (taskkill /T, /F for hard). Soft is a no-op for console trees
13
+ * (taskkill errors "can only be terminated forcefully") — the SIGTERM→grace→SIGKILL ladder is
14
+ * effectively delay→hard-kill on win32; accepted, CC-identical (D2).
15
+ * - spawn = `detached:false` + `windowsHide:true` (no console window; no POSIX process group — the kill
16
+ * side uses the tree, not the group) (D3).
17
+ * - env = case-insensitive key collapse before spawn (win32 env keys are case-insensitive; a `Path`+`PATH`
18
+ * pair from case-sensitive Object.assign reaches CreateProcess as ONE undefined-which
19
+ * entry). Canonical casing = the first-seen key (process.env's native casing wins since the
20
+ * inherit base is spread first).
21
+ *
22
+ * ⚠️ The win32 branches are UNVERIFIED on a real machine until S5 (Windows CI runner) — design D5 discipline:
23
+ * structural tests only on mac/Linux; behavior-level bite happens on the first Windows-runner green.
24
+ */
25
+ import type { ChildProcess } from "node:child_process";
1
26
  import { killProcessTree } from "@sema-agent/core";
2
27
  export declare const IS_WIN32: boolean;
3
28
  export interface HostShell {
@@ -48,15 +73,52 @@ export declare function killTreeSoft(pid: number): void;
48
73
  /** Graceful tree kill where the CALLER has no escalation timer of its own (currently unused by the host lane —
49
74
  * exported for parity with core's surface; the bg driver keeps its own HOST_BG_KILL_GRACE_MS ladder). */
50
75
  export { killProcessTree };
51
- /** 登记回执 = 注销的唯一凭据(纯数据;身份即凭据,见簿注)。 */
76
+ /**
77
+ * 登记回执 = 注销的**唯一**凭据(身份即凭据,见簿注)+ 一只幂等的注销把手。
78
+ *
79
+ * S-566 归一轮:注销**只有这一张脸**(`release()`)。此前另有一只 `untrackHostForegroundChild(ticket)`
80
+ * 自由函数,于是「把这只回执从簿上删掉」有两种写法 —— 零别名纪律下这属于歧义面,已**整个删除**
81
+ * (硬 breaking 三句:没有别名、没有过渡方法,调用方的 tsc 红就是通知)。
82
+ */
52
83
  export interface ForegroundChildTicket {
53
84
  /** 组领袖 pid = pgid(POSIX `detached:true` 的直接后果)。 */
54
85
  readonly pid: number;
86
+ /**
87
+ * 提前注销 —— 给各腿**特有**的兜底时点用(settle / `close` / 领养移交 / 生成器 `finally`)。
88
+ * 幂等(同一只回执 release 多少次都一样),且按**身份**删 ⇒ 迟到的那一次**绝不**碰内核复用同号之后
89
+ * 的新登记。主时点(`exit` / `error`)由 {@link adoptHostForegroundChild} 自己挂,调用点不必重复写。
90
+ */
91
+ release(): void;
55
92
  }
56
- /** 登记一只自起的前台子进程,返回注销用的回执。 */
93
+ /**
94
+ * 登记一只自起的前台子进程(**低层原语**:只有 pid 在手时用它 —— 产品码一律走
95
+ * {@link adoptHostForegroundChild},见那条形状钉)。
96
+ */
57
97
  export declare function trackHostForegroundChild(pid: number): ForegroundChildTicket;
58
- /** 注销(`exit` / settle / 领养移交)。按回执身份删 ⇒ 幂等,且**绝不**碰同号的其它登记。 */
59
- export declare function untrackHostForegroundChild(ticket: ForegroundChildTicket): void;
98
+ /**
99
+ * 🔴 **本簿在 `src/` 的唯一登记口**(S-566 归一轮)—— 一只自起的 detached 前台子进程「进簿 + 两只主注销」
100
+ * 的**全部**语义收在这里。
101
+ *
102
+ * ── 为什么是一只函数而不是五处同一句 ────────────────────────────────────────────────────────────
103
+ * 归一前,这一句在仓里**手抄五份**(`remote-env-host.ts` 三条 fg 腿 + `hook-runner.ts` 两条):
104
+ * 「`pid != null` 才登记」「`exit` 主注销」「`error` 之后 `exit` 可能不来,所以也要注销」——
105
+ * 每多一条 detached 腿就要再抄一遍,而**第六处只要漏抄一臂就是一个静默的孤儿洞**(S-566 的病正是
106
+ * hook 那两条腿整份没抄)。一条规则答完所有腿,规则集因此变小而不是变大。
107
+ *
108
+ * ── 语义(三条,逐条有理由)────────────────────────────────────────────────────────────────────
109
+ * · **无 pid ⇒ `undefined`,不登记**:spawn 起手失败那一族根本没有组领袖,簿上放一个 `undefined` 的坐标
110
+ * 等于给收割腿一条假线索;调用点因此拿到的是一个「可能不在」的把手,漏判会被类型挡住。
111
+ * · **`exit` = 主注销时点**(不是 `close` / settle):`close` 是**管道**驱动的,一个继承了 stdout 的孙进程
112
+ * 能把它按住任意久;而一条已退的 pid 留在簿里 = 给收割腿一个**过期坐标** —— 内核复用那个号之后
113
+ * `kill(-pid)` 打的是**别人**的组。
114
+ * · **`error` 也注销**:Node 契约里 `error` 之后 `exit` **可能不来**(异步起手失败),那一形没有第二次机会。
115
+ * 本函数只挂注销,**不**吞 `error` —— 五条腿各自的 `error` 处置(记账 / settle / 铸故障帧)原样在位,
116
+ * 本监听是第 N 只监听器,不改变「`error` 有没有人接」这件事(亲核:五处在归一前就都已有 `error` 监听)。
117
+ *
118
+ * 各腿**特有**的兜底(settle / `close` / 领养移交 / 生成器 `finally`)留在调用点,写成一行
119
+ * `ticket?.release()` —— 它们是**那条腿自己的**生命周期事实,不是本簿的语义。
120
+ */
121
+ export declare function adoptHostForegroundChild(child: ChildProcess): ForegroundChildTicket | undefined;
60
122
  /** 一次前台收割的读数。三格分开是**刻意**的:`alreadyDead` 是想要的结果(不是失败),`failed` 是**真的**
61
123
  * 没收上来 —— 两者混成一个数就等于把后者静默掉([ref]:这条腿上不许有安静的兜底)。
62
124
  *