@sema-agent/server 7.80.2 → 7.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/USAGE.md +40 -4
  2. package/dist/approval-ask-audit-store.d.ts +13 -7
  3. package/dist/boot/memory-consolidation.d.ts +38 -0
  4. package/dist/boot/memory-consolidation.js +14 -0
  5. package/dist/boot/reapers.js +1 -2
  6. package/dist/boot/runner-deps.d.ts +11 -0
  7. package/dist/boot/runner-deps.js +15 -0
  8. package/dist/boot/stage-06-runners.js +13 -8
  9. package/dist/config-catalog.js +1 -0
  10. package/dist/config-types.d.ts +26 -0
  11. package/dist/config.js +9 -1
  12. package/dist/http/routes/diagnostics.d.ts +3 -2
  13. package/dist/observability/fail-open.d.ts +8 -0
  14. package/dist/observability/fail-open.js +8 -0
  15. package/dist/observability/metrics.js +1 -0
  16. package/dist/plugins/approval-ask-store-file.d.ts +171 -0
  17. package/dist/plugins/approval-ask-store-file.js +258 -0
  18. package/dist/plugins/approval-ask-store-memory.d.ts +60 -2
  19. package/dist/plugins/approval-ask-store-memory.js +30 -12
  20. package/dist/plugins/checkpoint-store-sql.d.ts +22 -0
  21. package/dist/plugins/checkpoint-store-sql.js +1 -1
  22. package/dist/plugins/file-run-store.d.ts +6 -2
  23. package/dist/plugins/file-run-store.js +2 -1
  24. package/dist/plugins/local-checkpoint-store.d.ts +29 -1
  25. package/dist/plugins/local-checkpoint-store.js +24 -1
  26. package/dist/plugins/posix-shell-fs.d.ts +2 -1
  27. package/dist/plugins/posix-shell-fs.js +4 -1
  28. package/dist/plugins/remote-env-adb.d.ts +5 -1
  29. package/dist/plugins/remote-env-adb.js +3 -0
  30. package/dist/plugins/remote-env-e2b.d.ts +8 -1
  31. package/dist/plugins/remote-env-e2b.js +4 -1
  32. package/dist/plugins/remote-env-host.d.ts +15 -1
  33. package/dist/plugins/remote-env-host.js +37 -1
  34. package/dist/plugins/remote-env-k8s.d.ts +5 -1
  35. package/dist/plugins/remote-env-k8s.js +3 -0
  36. package/dist/plugins/remote-env-local-docker.d.ts +5 -1
  37. package/dist/plugins/remote-env-local-docker.js +3 -0
  38. package/dist/plugins/remote-env-ssh.d.ts +5 -1
  39. package/dist/plugins/remote-env-ssh.js +3 -0
  40. package/dist/plugins/remote-shell.d.ts +78 -1
  41. package/dist/plugins/remote-shell.js +35 -0
  42. package/dist/plugins/store-backend.d.ts +9 -8
  43. package/dist/plugins/store-backend.js +7 -4
  44. package/dist/plugins/store-contracts.d.ts +20 -0
  45. package/dist/plugins/store-contracts.js +2 -0
  46. package/dist/tool-approval.d.ts +11 -6
  47. package/dist/tool-approval.js +0 -2
  48. package/dist/trace/core-keyset-guard.d.ts +6 -1
  49. package/dist/trace/project.d.ts +18 -0
  50. package/dist/trace/project.js +11 -0
  51. package/package.json +2 -2
@@ -0,0 +1,258 @@
1
+ import { closeSync, copyFileSync, openSync, readSync, statSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { AppendLog, atomicWriteFile, ensureDir, readJsonlRecords } from "@sema-agent/core";
4
+ import { recordFailOpen } from "../observability/fail-open.js";
5
+ import { StoreBootRefusalError } from "./store-contracts.js";
6
+ import { InMemoryApprovalAskStore, encodeDecideUpdatedInput, encodeStoreJsonPayload } from "./approval-ask-store-memory.js";
7
+ export const APPROVAL_ASK_DIR = "approval-asks";
8
+ export const APPROVAL_ASK_JOURNAL_FILE = "asks.jsonl";
9
+ export const ASK_JOURNAL_COMPACT_MIN_RECORDS = 2_000;
10
+ export const ASK_JOURNAL_COMPACT_GROWTH = 2;
11
+ const JOURNAL_OPS = [
12
+ "ensureAsk",
13
+ "transitionAsk",
14
+ "decideAsk",
15
+ "claimTerminal",
16
+ "expireAsk",
17
+ "bindBatch",
18
+ "abortBatch",
19
+ "deferReconcile",
20
+ "resolveProvisional",
21
+ "deleteByTask",
22
+ ];
23
+ const JOURNAL_OP_WORDS = new Set(JOURNAL_OPS);
24
+ export const JOURNAL_OP_TABLE_GATE = true;
25
+ function endsAtRecordBoundary(path) {
26
+ const size = statSync(path).size;
27
+ if (size === 0)
28
+ return true;
29
+ const fd = openSync(path, "r");
30
+ try {
31
+ const buf = Buffer.alloc(1);
32
+ readSync(fd, buf, 0, 1, size - 1);
33
+ return buf[0] === 0x0a;
34
+ }
35
+ finally {
36
+ closeSync(fd);
37
+ }
38
+ }
39
+ export class FileApprovalAskStore {
40
+ core;
41
+ path;
42
+ tmpDir;
43
+ log;
44
+ records = 0;
45
+ pinnedNow = null;
46
+ replayRejectedCount = 0;
47
+ corruptLineCount = 0;
48
+ corruptQuarantinePath;
49
+ constructor(root) {
50
+ const dir = join(root, APPROVAL_ASK_DIR);
51
+ ensureDir(dir);
52
+ this.tmpDir = join(dir, ".tmp");
53
+ ensureDir(this.tmpDir);
54
+ this.path = join(dir, APPROVAL_ASK_JOURNAL_FILE);
55
+ this.core = new InMemoryApprovalAskStore({ now: () => this.clockRead() });
56
+ const tornTail = existsSync(this.path) && !endsAtRecordBoundary(this.path);
57
+ const records = readJsonlRecords(this.path, (info) => {
58
+ this.corruptLineCount += 1;
59
+ recordFailOpen("server.approval-ask-journal.corrupt-record-skipped", `path=${info.path} reason=${info.reason}`);
60
+ });
61
+ for (const rec of records)
62
+ this.replayRecord(rec);
63
+ this.records = records.length;
64
+ if (tornTail || this.corruptLineCount > 0) {
65
+ const quarantine = `${this.path}.corrupt-${Date.now()}`;
66
+ copyFileSync(this.path, quarantine);
67
+ this.corruptQuarantinePath = quarantine;
68
+ console.warn(`FileApprovalAskStore: approval ask journal ${this.path} had ${tornTail ? "a torn tail" : "no torn tail"} and ` +
69
+ `${this.corruptLineCount} unparseable line(s) — replayed every COMPLETE record, quarantined the original at ${quarantine}, ` +
70
+ `and rewrote the live journal from the replayed state. A dropped record is a LOST approval-ask transition: inspect the quarantined file.`);
71
+ this.compact();
72
+ }
73
+ }
74
+ clockRead() {
75
+ const pinned = this.pinnedNow;
76
+ if (pinned === null) {
77
+ throw new Error("FileApprovalAskStore: the in-memory core read its clock OUTSIDE a journalled window — a clock read after the " +
78
+ "first `await` cannot be replayed byte-identically. Move the read into the method's synchronous prefix " +
79
+ "(see the clock discipline in approval-ask-store-memory.ts).");
80
+ }
81
+ return pinned;
82
+ }
83
+ logFor() {
84
+ return (this.log ??= new AppendLog(this.path));
85
+ }
86
+ write(call, run) {
87
+ const t = Date.now();
88
+ const record = { v: 1, k: "op", t, ...call };
89
+ const encoded = encodeStoreJsonPayload(`${call.op} call`, record);
90
+ this.logFor().append(encoded, true);
91
+ this.records += 1;
92
+ this.pinnedNow = t;
93
+ let out;
94
+ try {
95
+ out = run(encoded);
96
+ }
97
+ finally {
98
+ this.pinnedNow = null;
99
+ }
100
+ try {
101
+ this.maybeCompact();
102
+ }
103
+ catch (e) {
104
+ recordFailOpen("server.approval-ask-journal.compaction-failed", `path=${this.path} err=${e instanceof Error ? e.message : String(e)}`);
105
+ }
106
+ return out;
107
+ }
108
+ maybeCompact() {
109
+ if (this.records < ASK_JOURNAL_COMPACT_MIN_RECORDS)
110
+ return;
111
+ const snapshot = this.core.exportRows();
112
+ if (this.records < (snapshot.asks.length + snapshot.batches.length) * ASK_JOURNAL_COMPACT_GROWTH)
113
+ return;
114
+ this.compact(snapshot);
115
+ }
116
+ compact(snapshot = this.core.exportRows()) {
117
+ const lines = [
118
+ ...snapshot.batches.map((row) => `${JSON.stringify({ v: 1, k: "batch", row })}\n`),
119
+ ...snapshot.asks.map((row) => `${JSON.stringify({ v: 1, k: "ask", row })}\n`),
120
+ ];
121
+ this.log?.closeForSwap();
122
+ atomicWriteFile(this.tmpDir, this.path, lines.join(""));
123
+ this.records = lines.length;
124
+ }
125
+ replayRecord(raw) {
126
+ if (raw === null || typeof raw !== "object") {
127
+ this.corruptLineCount += 1;
128
+ recordFailOpen("server.approval-ask-journal.corrupt-record-skipped", `path=${this.path} reason=record is not an object`);
129
+ return;
130
+ }
131
+ const probe = raw;
132
+ const refuse = (why) => {
133
+ throw new StoreBootRefusalError(`FileApprovalAskStore: approval ask journal ${this.path} carries a record this build cannot replay — ${why} ` +
134
+ `(v=${JSON.stringify(probe.v)} k=${JSON.stringify(probe.k)} op=${JSON.stringify(probe.op)}). ` +
135
+ "Replaying only the records it understands would resurrect decided asks or drop a human's approval. " +
136
+ "Run the server version that wrote this journal, or move the file aside (every approval on it is abandoned) to continue.");
137
+ };
138
+ if (probe.v !== 1)
139
+ refuse("unknown envelope version");
140
+ if (probe.k === "ask" || probe.k === "batch") {
141
+ const row = probe.row;
142
+ if (row === null || typeof row !== "object")
143
+ refuse("snapshot record without a row");
144
+ const key = probe.k === "ask" ? row.askId : row.batchId;
145
+ if (typeof key !== "string" || key.length === 0)
146
+ refuse(`snapshot record without a ${probe.k === "ask" ? "askId" : "batchId"}`);
147
+ if (probe.k === "ask")
148
+ this.core.importRows({ asks: [row] });
149
+ else
150
+ this.core.importRows({ batches: [row] });
151
+ return;
152
+ }
153
+ if (probe.k !== "op")
154
+ refuse("unknown record kind");
155
+ if (typeof probe.op !== "string" || !JOURNAL_OP_WORDS.has(probe.op))
156
+ refuse("verb outside this build's closed set (a DOWNGRADE: a newer server wrote it)");
157
+ if (typeof probe.t !== "number" || !Number.isFinite(probe.t))
158
+ refuse("call record without a clock reading");
159
+ const rec = raw;
160
+ this.pinnedNow = rec.t;
161
+ try {
162
+ void this.replayOp(rec).catch((e) => {
163
+ this.replayRejectedCount += 1;
164
+ console.warn(`FileApprovalAskStore: replaying ${rec.op} from ${this.path} was REFUSED by the store's own rules ` +
165
+ `(${e instanceof Error ? e.message : String(e)}). The live call was refused the same way and changed nothing, ` +
166
+ "so the state is faithful — but a record nobody can apply should not be on disk; inspect the journal.");
167
+ });
168
+ }
169
+ finally {
170
+ this.pinnedNow = null;
171
+ }
172
+ }
173
+ replayOp(rec) {
174
+ switch (rec.op) {
175
+ case "ensureAsk":
176
+ return this.core.ensureAsk(rec.row);
177
+ case "transitionAsk":
178
+ return this.core.transitionAsk(rec.askId, rec.from, rec.to, rec.patch);
179
+ case "decideAsk":
180
+ return this.core.decideAsk(rec.askId, rec.batchId, rec.decision);
181
+ case "claimTerminal":
182
+ return this.core.claimTerminal(rec.askId, rec.batchId, rec.intent);
183
+ case "expireAsk":
184
+ return this.core.expireAsk(rec.askId, rec.batchId);
185
+ case "bindBatch":
186
+ return this.core.bindBatch(rec.batchId, rec.askId, rec.gate);
187
+ case "abortBatch":
188
+ return this.core.abortBatch(rec.batchId);
189
+ case "deferReconcile":
190
+ return this.core.deferReconcile(rec.askId, rec.expectedState, rec.expectedRev, rec.nowMs);
191
+ case "resolveProvisional":
192
+ return this.core.resolveProvisional(rec.askId, rec.from, rec.to, rec.patch);
193
+ case "deleteByTask":
194
+ return this.core.deleteByTask(rec.taskId);
195
+ default: {
196
+ const unreachable = rec;
197
+ throw new Error(`FileApprovalAskStore: unreachable journal op ${JSON.stringify(unreachable)}`);
198
+ }
199
+ }
200
+ }
201
+ async ensureAsk(row) {
202
+ return this.write({ op: "ensureAsk", row }, (c) => this.core.ensureAsk(c.row));
203
+ }
204
+ async transitionAsk(askId, from, to, patch) {
205
+ return this.write({ op: "transitionAsk", askId, from, to, patch }, (c) => this.core.transitionAsk(c.askId, c.from, c.to, c.patch));
206
+ }
207
+ async decideAsk(askId, batchId, decision) {
208
+ const journalled = decision.updatedInput === undefined ? decision : { ...decision, updatedInput: encodeDecideUpdatedInput(decision.updatedInput) };
209
+ return this.write({ op: "decideAsk", askId, batchId, decision: journalled }, (c) => this.core.decideAsk(c.askId, c.batchId, c.decision));
210
+ }
211
+ async claimTerminal(askId, batchId, intent) {
212
+ return this.write({ op: "claimTerminal", askId, batchId, intent }, (c) => this.core.claimTerminal(c.askId, c.batchId, c.intent));
213
+ }
214
+ async expireAsk(askId, batchId) {
215
+ return this.write({ op: "expireAsk", askId, batchId }, (c) => this.core.expireAsk(c.askId, c.batchId));
216
+ }
217
+ async bindBatch(batchId, askId, gate) {
218
+ return this.write({ op: "bindBatch", batchId, askId, gate }, (c) => this.core.bindBatch(c.batchId, c.askId, c.gate));
219
+ }
220
+ async abortBatch(batchId) {
221
+ return this.write({ op: "abortBatch", batchId }, (c) => this.core.abortBatch(c.batchId));
222
+ }
223
+ async deferReconcile(askId, expectedState, expectedRev, nowMs) {
224
+ return this.write({ op: "deferReconcile", askId, expectedState, expectedRev, nowMs }, (c) => this.core.deferReconcile(c.askId, c.expectedState, c.expectedRev, c.nowMs));
225
+ }
226
+ async resolveProvisional(askId, from, to, patch) {
227
+ return this.write({ op: "resolveProvisional", askId, from, to, patch }, (c) => this.core.resolveProvisional(c.askId, c.from, c.to, c.patch));
228
+ }
229
+ async deleteByTask(taskId) {
230
+ return this.write({ op: "deleteByTask", taskId }, (c) => this.core.deleteByTask(c.taskId));
231
+ }
232
+ listByState(state, limit) {
233
+ return this.core.listByState(state, limit);
234
+ }
235
+ listPendingByTask(taskId, signal) {
236
+ return this.core.listPendingByTask(taskId, signal);
237
+ }
238
+ listPendingBySession(sessionId, owner, signal) {
239
+ return this.core.listPendingBySession(sessionId, owner, signal);
240
+ }
241
+ getAsk(askId) {
242
+ return this.core.getAsk(askId);
243
+ }
244
+ getByIdempotencyKey(taskId, key) {
245
+ return this.core.getByIdempotencyKey(taskId, key);
246
+ }
247
+ getBatch(batchId) {
248
+ return this.core.getBatch(batchId);
249
+ }
250
+ replayStats() {
251
+ return { rejected: this.replayRejectedCount, corruptLines: this.corruptLineCount, records: this.records };
252
+ }
253
+ dispose() {
254
+ this.log?.close();
255
+ this.log = undefined;
256
+ }
257
+ }
258
+ //# sourceMappingURL=approval-ask-store-file.js.map
@@ -1,9 +1,22 @@
1
1
  /**
2
- * `InMemoryApprovalAskStore` —— [ref] 车1,`ApprovalAskStore` 的语义真源(twin 对拆基准)+ LOCAL
3
- * 车道占位(File 形不在本车,见设计定稿 §7)。单进程内两个 `Map`,方法体全同步(不含 `await` 中断点)——
2
+ * `InMemoryApprovalAskStore` —— [ref] 车1,`ApprovalAskStore` 的语义真源(twin 对拆基准)。单进程内两个
3
+ * `Map`,方法体全同步(不含 `await` 中断点)——
4
4
  * Node 单线程下这就是天然原子:`Promise.all` 发起的并发调用不会在方法体内部交错执行,与
5
5
  * `approval-ask-store-sql.ts` 的 CAS UPDATE 语义逐字段一致(SQL twin 对拆套件的基准就是这份实现)。
6
6
  *
7
+ * 🔴 **S-384:本类同时是 `FileApprovalAskStore` 的内核**(local 车道的持久形不再是「第二份手抄」——
8
+ * 它把本类**原样**包起来,每次写前先把**调用本身**落 JSONL、重放时用同一只内核重放同一串调用)。
9
+ * 为此本类多了三件、且**只**多这三件(全是数据面,零转移判定):
10
+ * · {@link InMemoryApprovalAskStore.constructor} 的可注入钟 `now` —— 五个动词(decideAsk / claimTerminal /
11
+ * expireAsk / bindBatch / abortBatch)原先直读 `Date.now()`,那是重放路上唯一的不确定源:同一串调用
12
+ * 重放出来的 `updatedAtMs`/`decidedAtMs` 必须与当初**逐字节**相同,否则「重放等价」是假的。
13
+ * ⚠️ **钟只许在方法体的同步前缀里读**(第一个 `await` 之前)—— File 形据此把钟钉在一次调用的同步窗口
14
+ * 内,窗口外读钟会被它的钟函数**响亮拒**(不是静默回落 `Date.now()`)。今天五个动词全部满足;
15
+ * 哪天有人把读钟挪到 `await` 之后,File 形的钟当场抛,而不是悄悄写出一条重放不出来的行。
16
+ * · {@link InMemoryApprovalAskStore.exportRows} / {@link InMemoryApprovalAskStore.importRows} —— 压实用的
17
+ * 整本快照口(纯搬行,不经任何谓词)。压实要把「一串调用」折成「一串终局行」,没有这一对就只能在
18
+ * File 形里另写一套「哪些行还算数」的判据 —— 那才是第二份手抄。
19
+ *
7
20
  * 行为口径与 `SqlApprovalAskStore` 逐条对齐:
8
21
  * - ensureAsk 幂等 upsert(ask_id 已在 ⇒ 返回既有行,不改写;batch 行若不存在则以 OPEN 补齐)。
9
22
  * - transitionAsk 先过 machine 层 `canAskTransition`(非法转移 throw,不返回 false)。
@@ -13,9 +26,54 @@
13
26
  */
14
27
  import { type AskState, type BatchState } from "../approval-ask-machine.js";
15
28
  import type { AskDecision, AskRow, AskTerminalClaimIntent, AskTerminalClaimOutcome, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, EnsureAskResult, DecideResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
29
+ /**
30
+ * [ref] 的 `updatedInput` **入店编码**——两 twin 共用的那一只(本函数是唯一真源)。
31
+ *
32
+ * 语义:镜像 SQL twin 的「存 JSON 文本再解回来」(`dialectJsonEncoder` → `parseJsonColumn`)。BigInt /
33
+ * 循环引用 ⇒ `JSON.stringify` 抛(两侧同抛);顶层函数 / `undefined` ⇒ `stringify` 交出 `undefined`
34
+ * (「编出来什么都不是」)⇒ 本函数**响亮拒**,而不是让它在库里变成 NULL、在内存里变成活函数。
35
+ *
36
+ * 🔴 为什么必须是一只**导出**函数而不是 `decideAsk` 里的内联段(S-384):`FileApprovalAskStore` 要在
37
+ * **落日志之前**把这一步做掉 —— 它的日志本身就是 JSON,而 `JSON.stringify` 对顶层函数是**静默丢键**
38
+ * 不是抛。内联在 decideAsk 里的话,File 形只能在自己那侧再写一遍同样的判定(第二份手抄),或者
39
+ * 让「活路径抛 / 重放路径成功 DECIDE」这条真分岔活下来。
40
+ *
41
+ * ⚠️ 它与 File 形对**整条调用**做的那次 JSON round-trip 是两件事,别合并:round-trip 保证的是
42
+ * 「内核看见的 == 重放看见的」(把静默丢键变成两边一致);本函数保证的是「编出来什么都不是 ⇒ 响亮拒」,
43
+ * 而那条判据只在 `updatedInput` 这一格上成立 —— 它的**缺席**与**值为 null** 在 wire 上是两回事
44
+ * (`AskRow.updatedInput` 三态),所以「被 round-trip 静默丢成缺席」在这一格上不是无害的归一。
45
+ */
46
+ export declare function encodeStoreJsonPayload(what: string, value: unknown): unknown;
47
+ /** [ref] `updatedInput` 的入店编码 = 上面那只的一次具名调用(词固定在一处,两 twin 与 File 形账本共用)。 */
48
+ export declare function encodeDecideUpdatedInput(updatedInput: unknown): unknown;
49
+ /** {@link InMemoryApprovalAskStore.exportRows} 的产出 / {@link InMemoryApprovalAskStore.importRows} 的入参
50
+ * —— **纯行**,零判定。压实(S-384 File 形)与测试的状态对拆共用。 */
51
+ export interface ApprovalAskRowsSnapshot {
52
+ asks: readonly AskRow[];
53
+ batches: readonly BatchRow[];
54
+ }
16
55
  export declare class InMemoryApprovalAskStore implements ApprovalAskStore {
17
56
  private readonly asks;
18
57
  private readonly batches;
58
+ /** 店钟(S-384)。缺省 = 本进程墙钟,与本类落地以来逐字同值;File 形注入一只**钉住的**钟,让
59
+ * 「同一串调用重放出同一份状态」成立。读钟纪律见类顶注(只许在同步前缀里读)。 */
60
+ private readonly now;
61
+ constructor(opts?: {
62
+ now?: () => number;
63
+ });
64
+ /**
65
+ * S-384:整本行快照(压实用)。**深拷到行级**(与每个读口交出 `{ ...row }` 同一条纪律:交出去的行
66
+ * 不再是店内那一份,调用方改它碰不到店)。行内的 `cardJson`/`updatedInput`/`decisionActor` 仍是引用 ——
67
+ * 与 `getAsk`/`listByState` 逐字同宽,不在这一口上单独加深拷(那会让快照口与读口对同一份 blob 给出
68
+ * 两种可变性)。
69
+ */
70
+ exportRows(): ApprovalAskRowsSnapshot;
71
+ /** S-384:把快照里的行**原样**装回两张 Map(同键覆盖)。零谓词、零状态机 —— 它不是一条转移,是重放
72
+ * 的起手状态。压实产物与本口是一对:压实写出 {@link exportRows} 的行,启动时由本口装回。 */
73
+ importRows(snapshot: {
74
+ asks?: readonly AskRow[];
75
+ batches?: readonly BatchRow[];
76
+ }): void;
19
77
  ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
20
78
  transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
21
79
  decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
@@ -20,9 +20,32 @@ function applyPatch(row, patch) {
20
20
  if (patch.provisional !== undefined)
21
21
  row.provisional = patch.provisional;
22
22
  }
23
+ export function encodeStoreJsonPayload(what, value) {
24
+ const text = JSON.stringify(value);
25
+ if (text === undefined)
26
+ throw new Error(`approval-ask-store: ${what} is not JSON-encodable (encodes to nothing) — the SQL twin cannot hold it either`);
27
+ const decoded = JSON.parse(text);
28
+ return decoded;
29
+ }
30
+ export function encodeDecideUpdatedInput(updatedInput) {
31
+ return encodeStoreJsonPayload("updatedInput", updatedInput);
32
+ }
23
33
  export class InMemoryApprovalAskStore {
24
34
  asks = new Map();
25
35
  batches = new Map();
36
+ now;
37
+ constructor(opts) {
38
+ this.now = opts?.now ?? Date.now;
39
+ }
40
+ exportRows() {
41
+ return { asks: [...this.asks.values()].map((r) => ({ ...r })), batches: [...this.batches.values()].map((b) => ({ ...b })) };
42
+ }
43
+ importRows(snapshot) {
44
+ for (const r of snapshot.asks ?? [])
45
+ this.asks.set(r.askId, { ...r });
46
+ for (const b of snapshot.batches ?? [])
47
+ this.batches.set(b.batchId, { ...b });
48
+ }
26
49
  async ensureAsk(row) {
27
50
  assertIntegralEpochMs("expiresAtMs", row.expiresAtMs);
28
51
  assertIntegralEpochMs("createdAtMs", row.createdAtMs);
@@ -90,7 +113,7 @@ export class InMemoryApprovalAskStore {
90
113
  async decideAsk(askId, batchId, decision) {
91
114
  if (decision.idempotencyKey != null)
92
115
  assertIdempotencyKeyShape(decision.idempotencyKey);
93
- const now = Date.now();
116
+ const now = this.now();
94
117
  const batch = this.batches.get(batchId);
95
118
  if (!batch || batch.state !== "OPEN") {
96
119
  const row = this.asks.get(askId);
@@ -108,13 +131,8 @@ export class InMemoryApprovalAskStore {
108
131
  }
109
132
  }
110
133
  let encodedUpdatedInput;
111
- if (decision.updatedInput !== undefined) {
112
- const text = JSON.stringify(decision.updatedInput);
113
- if (text === undefined)
114
- throw new Error("approval-ask-store(memory): updatedInput is not JSON-encodable (encodes to nothing) — the SQL twin cannot hold it either");
115
- const decoded = JSON.parse(text);
116
- encodedUpdatedInput = decoded;
117
- }
134
+ if (decision.updatedInput !== undefined)
135
+ encodedUpdatedInput = encodeDecideUpdatedInput(decision.updatedInput);
118
136
  batch.rev += 1;
119
137
  batch.updatedAtMs = now;
120
138
  ask.state = "DECIDED";
@@ -141,7 +159,7 @@ export class InMemoryApprovalAskStore {
141
159
  const scoped = this.asks.get(askId);
142
160
  if (scoped !== undefined && scoped.batchId !== batchId)
143
161
  return { claimed: false, current: { state: "absent" } };
144
- const won = await this.transitionAsk(askId, "STREAM_PENDING", "VOID", { updatedAtMs: Date.now() });
162
+ const won = await this.transitionAsk(askId, "STREAM_PENDING", "VOID", { updatedAtMs: this.now() });
145
163
  if (won)
146
164
  return { claimed: true, voidedSiblings: [] };
147
165
  }
@@ -149,7 +167,7 @@ export class InMemoryApprovalAskStore {
149
167
  return { claimed: false, current: projectAskTerminalClaimTruth(cur !== undefined && cur.batchId === batchId ? { ...cur } : null) };
150
168
  }
151
169
  async expireAsk(askId, batchId) {
152
- const now = Date.now();
170
+ const now = this.now();
153
171
  const batch = this.batches.get(batchId);
154
172
  if (!batch)
155
173
  return { won: false, voidedSiblings: [] };
@@ -176,7 +194,7 @@ export class InMemoryApprovalAskStore {
176
194
  return { won: true, voidedSiblings: voided };
177
195
  }
178
196
  async bindBatch(batchId, askId, gate) {
179
- const now = Date.now();
197
+ const now = this.now();
180
198
  const batch = this.batches.get(batchId);
181
199
  if (!batch)
182
200
  return { ok: false, batchState: "MISSING" };
@@ -216,7 +234,7 @@ export class InMemoryApprovalAskStore {
216
234
  return true;
217
235
  }
218
236
  async abortBatch(batchId) {
219
- const now = Date.now();
237
+ const now = this.now();
220
238
  const batch = this.batches.get(batchId);
221
239
  if (!batch || (batch.state !== "OPEN" && batch.state !== "ROUTING_UNBOUND"))
222
240
  return [];
@@ -369,6 +369,28 @@ export declare const DECIDED_REPLAY_SCAN_CAP = 8;
369
369
  * (plan_review / dry_run_review)的判词,那些门的决议入口不是本腿,回放它们等于跨门作答。
370
370
  */
371
371
  export declare function approvalDecisionOfWinner(winner: ResolvedOutcome | undefined, boundCallId: string): "approve" | "deny" | null;
372
+ /**
373
+ * checkpoint blob → `pendingAction.toolCallId`。
374
+ *
375
+ * 🔴 三态、**不是**两态(真双库实跑抓到的缺陷,2026-08-06):「读不出」与「读出来了、但这条 park 本来就
376
+ * 没有 toolCallId」是两件事,压成同一个 `undefined` 会把每一条 `plan_review`/`task_done`/`resource_limit`
377
+ * 腿(core 的 `PendingAction` 联合里三个成员结构上就没有 toolCallId)都误报成坏行。前者应标 `unparseable`
378
+ * 交给收敛器当「不确定」,后者是**确定的不匹配**——干净地不是候选。
379
+ * - `{ readable: false }` —— JSON 坏 / 形状根本不是对象:真的读不出。
380
+ * - `{ readable: true, toolCallId: null }` —— 读出来了,这条 park 无工具动作:确定不匹配。
381
+ * - `{ readable: true, toolCallId: "…" }` —— 读出来了,拿去比。
382
+ *
383
+ * 逐层 `typeof` 收窄,不做裸 `as` 断言:blob 是持久层读回来的 `unknown`,是信任边界(宪法 [ref])。
384
+ */
385
+ export declare function pendingActionToolCallId(blob: unknown): {
386
+ readable: true;
387
+ toolCallId: string | null;
388
+ boundInputHash: string | null;
389
+ kind: string | null;
390
+ sourceTaskId: string | null;
391
+ } | {
392
+ readable: false;
393
+ };
372
394
  /** `pending_steer_queue` 列承载的 CheckpointState 字段(= core `appendPendingSteer` / `readPendingSteerQueue`
373
395
  * 的入参形)。列是**唯一**权威(suspend 时写的 blob 从不带它)。 */
374
396
  type SteerColumnState = Pick<CheckpointState, "pendingSteerQueue">;
@@ -166,7 +166,7 @@ export function approvalDecisionOfWinner(winner, boundCallId) {
166
166
  }
167
167
  }
168
168
  }
169
- function pendingActionToolCallId(blob) {
169
+ export function pendingActionToolCallId(blob) {
170
170
  let parsed;
171
171
  try {
172
172
  parsed = typeof blob === "string" ? JSON.parse(blob) : blob;
@@ -1,6 +1,6 @@
1
1
  import { type UsageRow } from "../usage-analytics.js";
2
2
  import type { TaskStatus } from "@sema-agent/core";
3
- import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult, LatestRunRef } from "./store-contracts.js";
3
+ import { StoreBootRefusalError, type RunRecord, type SessionSummary, type RunEvent, type PersistedTaskResult, type LatestRunRef } from "./store-contracts.js";
4
4
  import type { RunStoreCheckpointProbe } from "./memory-run-store.js";
5
5
  import type { LedgerEventType } from "../trace/ledger-events.js";
6
6
  /**
@@ -13,8 +13,12 @@ import type { LedgerEventType } from "../trace/ledger-events.js";
13
13
  * catch 吞掉:operator 明确要的 **fail-closed 反而变成 fail-open**,而且丢的正是持久 run 账本(这根旋钮
14
14
  * 存在的全部理由)。判据锚在**类型**上,不在文案上 —— 文案会改,类型不会(同文件族先例:`AdoptionError`
15
15
  * 在同一个降级臂里也是按类型无条件重抛)。
16
+ *
17
+ * 🔴 **S-384:本类自此 `extends StoreBootRefusalError`**(族规基类,见那条顶注)。上面这段论证对
18
+ * **每一只**会在 `LocalBackend` 构造器里拒启的 File 店逐字成立,而逐个 `if instanceof` 手工登记会漏 ——
19
+ * S-384 的 ask 账本拒启就漏过一次。继承 = 自动落在正确的一侧。
16
20
  */
17
- export declare class RunStoreStrictHydrateError extends Error {
21
+ export declare class RunStoreStrictHydrateError extends StoreBootRefusalError {
18
22
  readonly name = "RunStoreStrictHydrateError";
19
23
  }
20
24
  export declare class FileRunStore {
@@ -6,6 +6,7 @@ import { randomBytes } from "node:crypto";
6
6
  import { terminalProjection } from "@sema-agent/core";
7
7
  import { AppendLog, atomicWriteFile, ensureDir, isProcessLive, processFingerprint, readJsonlRecords, sanitizePathComponent, writeThenLink } from "@sema-agent/core";
8
8
  import { recordFailOpen } from "../observability/fail-open.js";
9
+ import { StoreBootRefusalError } from "./store-contracts.js";
9
10
  import { notifyRunTerminal } from "../observability/run-terminal-log.js";
10
11
  import { migrateRetiredAskOrigin } from "./one-time-migrations.js";
11
12
  const ownerEq = (a, b) => (a ?? null) === (b ?? null);
@@ -21,7 +22,7 @@ function readClaimRecord(recs, sessionId) {
21
22
  return c;
22
23
  }
23
24
  const PARK_ORPHAN_SETTLE_MS = 30_000;
24
- export class RunStoreStrictHydrateError extends Error {
25
+ export class RunStoreStrictHydrateError extends StoreBootRefusalError {
25
26
  name = "RunStoreStrictHydrateError";
26
27
  }
27
28
  export class FileRunStore {
@@ -1,5 +1,5 @@
1
1
  import { type Checkpoint, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ReopenReason, type ResolveExpectation, type ExecutionOutcomeRecordWord, type GateOutcome, type ResumeOutcome, type StoreDurability, type StoreFidelity } from "@sema-agent/core";
2
- import { type DecidedApprovalRecord, type PendingCheckpoint, type PendingRowBinding } from "./checkpoint-store-sql.js";
2
+ import { type CheckpointAskCandidate, type DecidedApprovalRecord, type PendingCheckpoint, type PendingRowBinding } from "./checkpoint-store-sql.js";
3
3
  export interface LocalCheckpointStoreOptions {
4
4
  /** taskId join for the pending card (the local FileRunStore's `getActiveTaskId`). Absent ⇒ `taskId: null`. */
5
5
  getActiveTaskId?: (sessionId: string) => Promise<string | undefined>;
@@ -130,6 +130,34 @@ export declare class LocalCheckpointStore {
130
130
  * 投了就是「SQL 车道有、local 车道没有」的键集分岔。
131
131
  */
132
132
  findDecidedApprovalsForBinding(sessionId: string, boundCallId: string): Promise<DecidedApprovalRecord[]>;
133
+ /**
134
+ * S-384 —— 对账收敛器**判据 1**(身份三元组 ∧ hash 双等 ⇒ `bindBatch`)的读口在 local 车道的形。
135
+ * 语义、判别纪律、`unparseable` 的含义,唯一属主 = SQL 孪生 `findCheckpointCandidatesForAsk` 的顶注与
136
+ * `CheckpointAskCandidate` 的字段注;这里只记**本形自己的三处差别**。
137
+ *
138
+ * 🔴 **为什么必须有这一口**(不是锦上添花):S-384 让 local 车道有了持久 ask 账,于是收敛器在这条车道上
139
+ * 第一次真的有行可扫。缺这一口 ⇒ 判据 1 结构性不命中 ⇒ **窗到期 park 的 ask 行永远停在 `PARKING`**,
140
+ * 而那个态在 wire 上是有后果的:`POST /v1/tool-approvals/:id/respond` 的 `PARKING` 臂答的是
141
+ * `202 parking_in_progress + retryAfterMs`(它的前提是「`bindBatch` 毫秒级就到」),在一台绑不了的机器上
142
+ * 那句「再试一次」**永远不会兑现**;run 跑到终局后判据 2 还会把它收成
143
+ * `DENIED(routing_failure_fail_closed)` ⇒ 迟到答拿到的判别位是 `denied` —— 对一条**人真的批准过**的 ask
144
+ * 撒谎。两条都是相对 7.80.2 的回归,根因同一个:**判据 1 的读口只有 SQL 孪生有**。修在源头 = 把这一口
145
+ * 补齐,而不是给下游那两处各加一条「这台机器绑不了」的特判。
146
+ *
147
+ * 形差(逐条,如实):
148
+ * · **枚举面**走 [ref] 的 `sessionTokens` 索引(与 `hasExpiredBySession` / `findDecidedApprovalsForBinding`
149
+ * 同一条路),所以它那条**存量限制**在这里逐字同样成立:索引建立之前 put 的老行对本口不可见 ⇒ 判据 1
150
+ * 不命中 ⇒ 行落判据 ②③④⑤(保守方向,与读口缺席时的既有降级同向)。
151
+ * · **没有列,所以没有列/blob 一致性门**:SQL 孪生的两道矛盾门(`tool_call_id` 与 `bound_input_hash`
152
+ * 的列 vs blob)在本形结构上不存在 —— 本形只有 blob 这一个真源,谈不上两份互相矛盾。判别力因此**不弱**:
153
+ * 那两道门防的是「外部改库 / schema 偏斜让列与 blob 分家」,而本形没有可分家的第二份。
154
+ * · **没有行级版本闸**:本类 `peekScopeByToken` 顶注已亲核过这条事实 —— 本形的 `get()` 没有行级版本闸,
155
+ * 唯一的读不动形是账本重放级(整店同时读不动)。所以 SQL 孪生那道 `version > MAX_SUPPORTED` 的
156
+ * `unparseable` 臂在这里没有对应物;本口的 `unparseable` 只由**窄读器**判(blob 形不合)。
157
+ * · **窄读器是同一只** `pendingActionToolCallId`(与 SQL 孪生共用,零第二份手抄):身份三元组的第一维
158
+ * `sourceTaskId`、`boundInputHash`、以及「读不出 vs 确定不匹配」的三态,全部由它一家判。
159
+ */
160
+ findCheckpointCandidatesForAsk(scope: string, sessionId: string, toolCallId: string, sinceMs: number): Promise<CheckpointAskCandidate[]>;
133
161
  /**
134
162
  * [ref]:按 token 的 scope 读 —— 与 SQL 孪生**同一条契约**(`undefined` 无行 / `null` 匿名 / 属主串),
135
163
  * 语义与存在理由的唯一属主 = SQL 侧 `peekScopeByToken` 顶注。
@@ -4,7 +4,7 @@ import { existsSync, readFileSync, readdirSync, renameSync, unlinkSync, mkdirSyn
4
4
  import { join } from "node:path";
5
5
  import { isApprovalGateKind } from "../tool-approval.js";
6
6
  import { FileCheckpointStore, atomicWriteFile, executionVerdict, sanitizePathComponent, } from "@sema-agent/core";
7
- import { approvalDecisionOfWinner, approvalPayloadFingerprint, boundedRuleOffers, boundedToolInput, pendingRowPrecedes, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
7
+ import { approvalDecisionOfWinner, approvalPayloadFingerprint, boundedRuleOffers, boundedToolInput, pendingActionToolCallId, pendingRowPrecedes, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
8
8
  import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
9
9
  function terminalAtOf(cp) {
10
10
  return Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS);
@@ -236,6 +236,29 @@ export class LocalCheckpointStore {
236
236
  }
237
237
  return out;
238
238
  }
239
+ async findCheckpointCandidatesForAsk(scope, sessionId, toolCallId, sinceMs) {
240
+ const tokens = this.sessionTokens.get(sessionId);
241
+ if (!tokens)
242
+ return [];
243
+ const out = [];
244
+ for (const t of [...tokens]) {
245
+ const cp = await this.inner.get(t);
246
+ if (!cp)
247
+ continue;
248
+ if (cp.scope !== scope || cp.sessionId !== sessionId || cp.createdAt < sinceMs)
249
+ continue;
250
+ const base = { token: t, status: cp.status, createdAtMs: cp.createdAt };
251
+ const derived = pendingActionToolCallId(cp);
252
+ if (!derived.readable) {
253
+ out.push({ ...base, sourceTaskId: null, boundCallId: null, boundInputHash: null, unparseable: true });
254
+ continue;
255
+ }
256
+ if (derived.toolCallId !== toolCallId)
257
+ continue;
258
+ out.push({ ...base, sourceTaskId: derived.sourceTaskId, boundCallId: derived.toolCallId, boundInputHash: derived.boundInputHash });
259
+ }
260
+ return out.sort((a, b) => a.createdAtMs - b.createdAtMs || (a.token < b.token ? -1 : a.token > b.token ? 1 : 0));
261
+ }
239
262
  async peekScopeByToken(token) {
240
263
  return (await this.inner.get(token))?.scope;
241
264
  }
@@ -1,4 +1,4 @@
1
- import { FileError, type ExecutionError, type RemoteExecutionError, type FileInfo, type Result } from "@sema-agent/core";
1
+ import { FileError, type ExecutionError, type RemoteExecutionError, type FileInfo, type Result, type SymlinkChain } from "@sema-agent/core";
2
2
  export interface ShellRunResult {
3
3
  stdout: string;
4
4
  stderr: string;
@@ -14,6 +14,7 @@ export interface PosixShellFsDeps {
14
14
  }
15
15
  export interface PosixShellFs {
16
16
  readLink(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
17
+ canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
17
18
  canonicalPath(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
18
19
  statFileInfo(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
19
20
  listDir(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { FileError, } from "@sema-agent/core";
3
- import { shellQuote, kindFromMode } from "./remote-shell.js";
3
+ import { runSymlinkChain, shellQuote, kindFromMode } from "./remote-shell.js";
4
4
  import { classifyFsStderr, fileErrorFromExec } from "./remote-env-file-error.js";
5
5
  const ok = (value) => ({ ok: true, value });
6
6
  export function createPosixShellFs(deps) {
@@ -22,6 +22,9 @@ export function createPosixShellFs(deps) {
22
22
  return { ok: false, error: classifyFsStderr(r.value.stderr, abs, "not_found") };
23
23
  return ok(r.value.stdout.trim());
24
24
  },
25
+ async canonicalChain(p, abortSignal) {
26
+ return runSymlinkChain(resolve(p), (cmd) => run(cmd, abortSignal));
27
+ },
25
28
  async canonicalPath(p, abortSignal) {
26
29
  const abs = resolve(p);
27
30
  const r = await run(`realpath -- ${shellQuote(abs)}`, abortSignal);
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory } from "@sema-agent/core";
2
+ import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory } from "@sema-agent/core";
3
3
  type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
4
4
  export interface AdbEnvConfig {
5
5
  /** Device serial or `host:port` (TCP). `host:port` triggers `adb connect` on connect()/reconnect(). */
@@ -139,6 +139,10 @@ export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv {
139
139
  fileInfo(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
140
140
  listDir(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
141
141
  readLink(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
142
+ /** S-362 / core 7.21.0 [ref] —— **实现**(表态:本腿实现 `canonicalChain`)。一次往返里的一个 `readlink`
143
+ * 循环(上限 64、只锚不折、遇环即止),脚本与读法的单一属主在 `remote-shell.ts`,四条 shell 腿共用同一份。
144
+ * 缺席读法用不上(本腿在场);`ok:false` 由 core 读作「这一次没有跳」,永不是拒绝。 */
145
+ canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
142
146
  canonicalPath(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
143
147
  exists(p: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
144
148
  createDir(p: string, options?: {