@sema-agent/server 7.80.2 → 7.81.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.
package/USAGE.md CHANGED
@@ -509,9 +509,13 @@ QUESTION_TTL_MS=300000
509
509
  # 回滚键是下面的 UNATTENDED_APPROVAL_POLICY=deny(两根旋钮正交,别当一根用);它也**不**再回滚窗长
510
510
  # (窗是活卡件不是协议件,见下一键——缺省值与旧硬编码窗同为 300000,没配过就仍然字节不变)
511
511
  STREAM_APPROVAL_ENABLED=true
512
- # 活卡窗(毫秒,默认 300000=5min)。**辖域=所有腿**:协议上不上场都按它计窗——local 后端 /
513
- # 无 checkpoint 店 / 关了协议的部署同样生效(此前那三形上本键一个字节都不生效、窗恒 5min,且没有任何
514
- # 一行说出来)。0 = 运维显式关窗 ⇒ **一张卡都不发**、恒走无人值守终局(不是"还原",还原用上面那个键;
512
+ # 活卡窗(毫秒)。**辖域=所有腿**:协议上不上场都按它计窗——无 checkpoint 店 / 关了协议的部署同样
513
+ # 生效(此前那几形上本键一个字节都不生效、窗恒 5min,且没有任何一行说出来)。
514
+ # 🔴 **缺省值是 posture 派生的,不是一个数**(S-178):单机 turnkey(`REQUIRE_PRINCIPAL` 未设)=
515
+ # **86400000(24 小时)**,多租户形 = 300000(5min)。**S-384 起这根旋钮在单机上是要读的那一根**:
516
+ # local 后端自此有持久 ask 账、流内协议上场,受门 ask 先发一张活卡、**窗满才转 park**(升级前是铸造
517
+ # 时刻立刻 park)⇒ 不配它的**无人值守**单机最多挂一天才落 park。无人值守机器请显式给几秒;
518
+ # 有人值守的单机正是那一天窗口的受益者。0 = 运维显式关窗 ⇒ **一张卡都不发**、恒走无人值守终局(不是"还原",还原用上面那个键;
515
519
  # 缺省 park 政策 + park 设施在场 ⇒ 全落 durable park 经 /v1/approvals/:sessionId/decide 兑现;
516
520
  # 配了 UNATTENDED_APPROVAL_POLICY=deny 或没有 park 设施的部署这里是"恒当场拒"= 受门工具全关,
517
521
  # boot 期打一行 stream_ask_window_zero 点名)。负值**拒启**(此前静默等价于 0 还白发一张没人赢得了的卡)
@@ -570,7 +574,9 @@ UNREACHED_ASK_TTL_MS=3600000
570
574
  耗尽(十几分钟量级)才感知——这段时间内真正的兜底是**活卡窗到期转 park**(`STREAM_ASK_WINDOW_MS`,
571
575
  默认 5min),所以把窗改大等于把断连场景的最坏无人区拉长,改小前先看上面的跨旋钮不变量。half-open 的
572
576
  主动探测(写水位判死)在攻关排期。
573
- - **无 durable 前置的部署(五合取不满足:local backend / 无 checkpoint 能力)**:流内协议整个不上场
577
+ - **无 durable 前置的部署(四合取不满足:无 backend / 无 checkpoint 能力 / 显式关协议 / 无活卡腿;
578
+ ⚠️ **S-384 起 `local` backend 不再是其中一项** —— 它有了文件形 ask 账,配齐 `DURABLE_APPROVAL=true`
579
+ + checkpoint 店的单机部署上协议**照常上场**,`GET /v1/capabilities` 的 `streamApproval` 报 true)**:流内协议整个不上场
574
580
  (启动一行 `stream_approval_disabled` 点名缺哪项),ask 走旧活卡腿——断连场景的最坏结局是活卡 TTL
575
581
  窗满**自动 deny(fail-closed)后 run 继续**,不会死锁在等一张没人能回的卡上;代价是断连期间用户的
576
582
  批准机会直接过期。(7.34.0 起这条 deny 由**引擎**在「没有可停靠的 durable 门」时给出——服务侧只报
@@ -580,6 +586,9 @@ UNREACHED_ASK_TTL_MS=3600000
580
586
  (卡挂过窗、人才按 Yes)不能走 `POST /v1/tool-approvals/:id/respond`(没有持久 ask 行 ⇒ 恒 404
581
587
  `tool_approval.not_pending`、不带 `cause`),要走 `POST /v1/approvals/:sessionId/decide`
582
588
  ——前提是配了 `DURABLE_APPROVAL=true`(local 车道有文件形 checkpoint 店),否则窗到期即 fail-closed 拒。
589
+ ⚠️ **上面这段「没有持久 ask 行 ⇒ 恒 404」自 S-384 起不再适用于 `DB_BACKEND=local` 本身**:local 的
590
+ ask 账已是文件形,配齐 park 设施的单机部署上迟到决议走 `respond` 的赎回席是通的;本段现在只说
591
+ 那几条**真的**不上场的部署形(无 backend / 无 checkpoint 能力 / 显式关协议)。
583
592
  默认值(300s + 60s vs 7d)自然满足,只有显式改坏才会撞上。
584
593
  - **调参方向**:想让人有更长时间点审批卡 → 调大 `STREAM_ASK_WINDOW_MS`;卡太多刷屏 → 调小两个 `ADMIT_MAX_*`
585
594
  (代价是超限的 ask 走 park,要有人去审批队列捞);库压大 → 调小 `STREAM_APPROVAL_RECONCILE_BATCH`
@@ -6,18 +6,24 @@
6
6
  * 崩溃安全机制照 `orchestration/workflow-notify-journal.ts` 的 File 形:append-only JSONL、fsync 逐行、
7
7
  * 重放容忍撕尾行)。
8
8
  *
9
- * ## 它治的病([ref] / [ref]① 车定界钉)
10
- * local 车道 ask 账进程内易失(`InMemoryApprovalAskStore`),活卡窗内崩溃 = **零痕迹**:重启后
11
- * `/v1/approvals` 两数组空,那次审批连一条审计行都没有。本店把 ask **铸造 / 决议 / 腿闭**三类事件
9
+ * ## 它治的病([ref] / [ref]① 车定界钉;⚠️ **病因描述是 [ref] 当时的事实,S-384 起已变**)
10
+ * 当时:local 车道 ask 账进程内易失(`InMemoryApprovalAskStore`),活卡窗内崩溃 = **零痕迹**:重启后
11
+ * `/v1/approvals` 两数组空,那次审批连一条审计行都没有。**S-384 起 local 的 ask 账已是持久的**
12
+ * (`FileApprovalAskStore`),所以「零痕迹」那句不再成立;本店今天还在的理由收窄成它**独有**的那一格:
13
+ * `crashConverged` 这个 local-only 的 additive 读面(孤儿行的 `orphanState` / `resumeSafe` 两位),
14
+ * ask 账本身没有「boot 收敛孤儿」这条动词。两本同根账并存是已登记的抽象欠账。本店把 ask **铸造 / 决议 / 腿闭**三类事件
12
15
  * append 进磁盘账本(挂线点 = `ToolApprovalCoordinator`,见 `askAudit` ctor opt),boot 时收敛器把
13
16
  * 孤儿行标 `crashed_before_park` 终态成因注,`GET /v1/approvals` 的 additive 键 `crashConverged` 供
14
17
  * 操作员追溯。
15
18
  *
16
19
  * ## 它**不**做的事(裁 (c) 的边界,与稿 §1 (a) 臂的语义论证同源)
17
- * - **不翻 `streamApproval` 能力位**:该位在 SQL 车道隐含「durable park 兜底(窗到期 park 可赎)」,
18
- * local 给不了(park 凭据由引擎铸 checkpoint 时发,进程死后 run 不可复活)—— 同一位两义 = 消费方
19
- * 按位渲染「稍后可批」时做出错误承诺。`resolveStreamApprovalGate` 的 `volatile_ask_ledger` 臂
20
- * 一字不动(钉:`test/approval-ask-audit.test.ts` [ref]-⑧ + e2e 定界格新形)。
20
+ * - **不翻 `streamApproval` 能力位**([ref] 当时的裁 (c);⚠️ **这一位已由 S-384 翻真,不是被本店翻的**):
21
+ * 当年不翻的理由是「该位在 SQL 车道隐含 durable park 兜底,local 给不了」+「local 的 ask 账是进程内
22
+ * 易失形」。后半条已由 `FileApprovalAskStore` 消掉(持久账落地);前半条的答案一直在谓词里 ——
23
+ * `resolveStreamApprovalGate` 的 `parkFacility` 合取项要求 `backend.checkpoint` 在场 ∧ `DURABLE_APPROVAL`,
24
+ * 所以位报 true 的 local 部署**必然**配齐了 File 形 checkpoint 店(`durable-approval-local-e2e` 第一格
25
+ * 实证:park 行跨双 kill 存活、重启后 decide 驱动 resume 真跑完)。本店与那一位自此无关:它治的是
26
+ * **活卡窗内崩溃**(park 还没落盘)那一格的零痕迹病,而那一格无论位真位假都不复活 run。
21
27
  * - **不复活 run**:审计行不是 park 行,崩掉的 ask 不进 pending/livePending。恢复闭环真形 =
22
28
  * core resume 既有补偿腿(悬空 tool_use 闭合「中断,工具未执行」⇒ 模型自然重发重弹卡),读面用
23
29
  * `resumeSafe` 位指路(v1.1 §6 第二件)。
@@ -110,10 +110,9 @@ export function startReapers(ctx) {
110
110
  const approvalReconciler = (() => {
111
111
  if (!config.streamApproval.enabled || !backend)
112
112
  return undefined;
113
- const cpPort = checkpointStore;
114
113
  return createApprovalReconciler({
115
114
  askStore: backend.approvalAsk(),
116
- checkpoints: typeof cpPort?.findCheckpointCandidatesForAsk === "function" ? cpPort : undefined,
115
+ ...(checkpointStore !== undefined ? { checkpoints: checkpointStore } : {}),
117
116
  runs: runStore,
118
117
  logger,
119
118
  metrics,
@@ -14,8 +14,9 @@ import type { IncomingMessage, ServerResponse } from "node:http";
14
14
  import type { WiringManifest } from "@sema-agent/core";
15
15
  import { type StreamApprovalGate, type StreamApprovalGateInput } from "../../tool-approval.js";
16
16
  import type { RouteCtx, RouteMatch, RouteIdsOf } from "../route-ctx.js";
17
- /** 流内审批门的读数形:上场即 `"active"`,否则是那五个合取项里**第一个**不满足的原因词
18
- * (与 `resolveStreamApprovalGate` 同一闭集,新增 reason 在此处编译红)。 */
17
+ /** 流内审批门的读数形:上场即 `"active"`,否则是那**四**个合取项里**第一个**不满足的原因词
18
+ * (与 `resolveStreamApprovalGate` 同一闭集,词表**取型**自它 ⇒ 增删 reason 在此处自动跟随/编译红;
19
+ * S-384 删掉第 5 项「账必须持久」时本行数字漏改过一次,**数字是手抄的那一半**)。 */
19
20
  export type StreamApprovalGateReading = "active" | Extract<StreamApprovalGate, {
20
21
  active: false;
21
22
  }>["reason"];
@@ -9,6 +9,14 @@ export interface FailOpenTagEntry {
9
9
  }
10
10
  /** 闭集 tag 词表。形=`<repo>.<domain>.<site>`(跨仓同形,便于三仓遥测并表)。 */
11
11
  export declare const FAIL_OPEN_TAGS: {
12
+ readonly "server.approval-ask-journal.compaction-failed": {
13
+ readonly cls: "F";
14
+ readonly note: "S-384(`plugins/approval-ask-store-file.ts` 的 `write()` 尾巴,local 车道的 File 形 ask 账):账本**压实**(整本重写成行快照)失败 —— 盘满 / 只读挂载 / `.tmp` 不可写。压实是**纯管家动作**,走到它的时候这一次写**已经 append+fsync 落盘、也已经翻进内存** ⇒ 把它的 I/O 错误抛给调用方 = 把一次已生效的人类批准报成失败(客户端重试会拿到 `ask_not_pending`),正是本店存在的理由被反过来用。所以吞 + 留痕:账本继续长(core 的 `AppendLog` 在 `closeForSwap` 之后惰性重开,一次瞬时错误不会把它变成砖),下一次写再试压实。放行的最坏后果 = 账本不再收缩(盘占用按写入量线性增长),**语义零影响**(行与转移一个不差)。计数非零 = 这台机器的数据根写不动了,运维要去看盘。";
15
+ };
16
+ readonly "server.approval-ask-journal.corrupt-record-skipped": {
17
+ readonly cls: "F";
18
+ readonly note: "S-384(`plugins/approval-ask-store-file.ts` 的启动重放,local 车道的 File 形 ask 账):账本**中间**有一行解析不出(位腐 / 被人手改 / 文件系统故障)⇒ core `readJsonlRecords` 跳过它,本店继续重放其余记录。**撕尾不走本 tag**(崩在半行是这个格式存在的理由,由 quarantine + warn 单独处置),**词表外动词也不走**(那是降级,直接拒启)。放行的最坏后果之所以有界,靠的是店的 CAS 形:每一条写都带 `WHERE state = <from>` 谓词 ⇒ 丢一条记录只能让**后续转移失败**(no-op),绝不可能让一条非法转移成功 —— 一条已决 ask 退回 STREAM_PENDING 的方向是 fail-closed(工具不会因此被放行),一条待决 ask 消失的方向是「等卡的那条腿窗到期走 park」= S-384 之前的行为。所以这条兜底丢的是**进度**,不是门。同批还有 quarantine 原件 + 一条点名路径的 warn;计数让「某台机器的 ask 账一直在腐」与「这台机器本来就没有审批」在遥测上分得开。";
19
+ };
12
20
  readonly "server.terminal.persisted-plane-malformed": {
13
21
  readonly cls: "F";
14
22
  readonly note: "S-136 合并重扫确认项(`terminal.ts persistedPlaneOf`,持久 blob 的平面读面):盘上一条 `terminal` 因由形不合(`paused` 缺 gate / 词出闭集)让 core 的 `terminalProjection` 抛 ⇒ 本读面对这一行答**全格缺席**而不是整只 500。放行的最坏后果=一条坏行在列表/舰队读面上显示为无终局词;计数 + probe 带原句,便于按行回溯。";
@@ -1,5 +1,13 @@
1
1
  import { appendFileSync } from "node:fs";
2
2
  export const FAIL_OPEN_TAGS = {
3
+ "server.approval-ask-journal.compaction-failed": {
4
+ cls: "F",
5
+ note: "S-384(`plugins/approval-ask-store-file.ts` 的 `write()` 尾巴,local 车道的 File 形 ask 账):账本**压实**(整本重写成行快照)失败 —— 盘满 / 只读挂载 / `.tmp` 不可写。压实是**纯管家动作**,走到它的时候这一次写**已经 append+fsync 落盘、也已经翻进内存** ⇒ 把它的 I/O 错误抛给调用方 = 把一次已生效的人类批准报成失败(客户端重试会拿到 `ask_not_pending`),正是本店存在的理由被反过来用。所以吞 + 留痕:账本继续长(core 的 `AppendLog` 在 `closeForSwap` 之后惰性重开,一次瞬时错误不会把它变成砖),下一次写再试压实。放行的最坏后果 = 账本不再收缩(盘占用按写入量线性增长),**语义零影响**(行与转移一个不差)。计数非零 = 这台机器的数据根写不动了,运维要去看盘。",
6
+ },
7
+ "server.approval-ask-journal.corrupt-record-skipped": {
8
+ cls: "F",
9
+ note: "S-384(`plugins/approval-ask-store-file.ts` 的启动重放,local 车道的 File 形 ask 账):账本**中间**有一行解析不出(位腐 / 被人手改 / 文件系统故障)⇒ core `readJsonlRecords` 跳过它,本店继续重放其余记录。**撕尾不走本 tag**(崩在半行是这个格式存在的理由,由 quarantine + warn 单独处置),**词表外动词也不走**(那是降级,直接拒启)。放行的最坏后果之所以有界,靠的是店的 CAS 形:每一条写都带 `WHERE state = <from>` 谓词 ⇒ 丢一条记录只能让**后续转移失败**(no-op),绝不可能让一条非法转移成功 —— 一条已决 ask 退回 STREAM_PENDING 的方向是 fail-closed(工具不会因此被放行),一条待决 ask 消失的方向是「等卡的那条腿窗到期走 park」= S-384 之前的行为。所以这条兜底丢的是**进度**,不是门。同批还有 quarantine 原件 + 一条点名路径的 warn;计数让「某台机器的 ask 账一直在腐」与「这台机器本来就没有审批」在遥测上分得开。",
10
+ },
3
11
  "server.terminal.persisted-plane-malformed": {
4
12
  cls: "F",
5
13
  note: "S-136 合并重扫确认项(`terminal.ts persistedPlaneOf`,持久 blob 的平面读面):盘上一条 `terminal` 因由形不合(`paused` 缺 gate / 词出闭集)让 core 的 `terminalProjection` 抛 ⇒ 本读面对这一行答**全格缺席**而不是整只 500。放行的最坏后果=一条坏行在列表/舰队读面上显示为无终局词;计数 + probe 带原句,便于按行回溯。",
@@ -0,0 +1,171 @@
1
+ import type { AskState } from "../approval-ask-machine.js";
2
+ import type { AskRow, AskTerminalClaimIntent, AskTerminalClaimOutcome, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, DecideResult, EnsureAskResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
3
+ /** 数据根下的目录名。**按店命名**,与兄弟 File 店同规(`approval-nonces` / `approval-ask-audit` /
4
+ * `approval-exemptions` / `resume-anchors` / `checkpoint-ctx` / …)—— 首版写的是族名 `approvals`,而那
5
+ * 正是读者会去找**持久 park 行**的地方(它们其实在 `checkpoint-ctx`),且持久数据面的名字按硬 breaking
6
+ * 三句是「不能靠编译红通知」的那一面,改名只在落地前廉价(替补对抗复审 F8,采纳)。 */
7
+ export declare const APPROVAL_ASK_DIR = "approval-asks";
8
+ /** 账本文件名。 */
9
+ export declare const APPROVAL_ASK_JOURNAL_FILE = "asks.jsonl";
10
+ /**
11
+ * 压实的**下界**:记录数不到这个数,压实的 I/O 不值得付(整本重写 + fsync)。
12
+ * 与 {@link ASK_JOURNAL_COMPACT_GROWTH} 一起构成闭形判据,零 env、零启发式。
13
+ */
14
+ export declare const ASK_JOURNAL_COMPACT_MIN_RECORDS = 2000;
15
+ /**
16
+ * 压实的**增长因子**:记录数必须同时超过「活行数 × 本因子」才压实。
17
+ * 少了它就是抖动 —— 一个稳定在 1_800 行的部署会在每条记录之后都把整本重写一遍。
18
+ */
19
+ export declare const ASK_JOURNAL_COMPACT_GROWTH = 2;
20
+ /**
21
+ * 账本的**动词闭集**([ref] 形)。运行期的词表判定与编译期的穷尽 `switch` 读同一份:
22
+ * 往这里加一个词,{@link FileApprovalAskStore.replayOp} 的 switch 立刻编译红(漏一口不可能发布)。
23
+ */
24
+ declare const JOURNAL_OPS: readonly ["ensureAsk", "transitionAsk", "decideAsk", "claimTerminal", "expireAsk", "bindBatch", "abortBatch", "deferReconcile", "resolveProvisional", "deleteByTask"];
25
+ type JournalOpWord = (typeof JOURNAL_OPS)[number];
26
+ /** 运行期词表(上面的数组)与类型面动词维({@link JournalCall})的**双向**对表 —— 任一边加词而另一边
27
+ * 没跟上,这个别名解析成 `never`,{@link JOURNAL_OP_TABLE_GATE} 的赋值当场编译红。 */
28
+ type JournalOpTableIsExhaustive = (JournalCall["op"] extends JournalOpWord ? (JournalOpWord extends JournalCall["op"] ? true : false) : false) extends true ? true : never;
29
+ /** 上面那条对表的**执法位**(占位常量形,`core-keyset-guard.ts` 的占位元组同精神):运行期无用,
30
+ * 存在的唯一理由是让「词表与类型面分家」在编译期就红,而不是等到某条记录重放不出来。 */
31
+ export declare const JOURNAL_OP_TABLE_GATE: JournalOpTableIsExhaustive;
32
+ /** 一次写调用的实参(动词维判别式)——盘上记的就是它,重放时原样喂回内核。 */
33
+ type JournalCall = {
34
+ op: "ensureAsk";
35
+ row: NewAskRow;
36
+ } | {
37
+ op: "transitionAsk";
38
+ askId: string;
39
+ from: AskState;
40
+ to: AskState;
41
+ patch: AskTransitionPatch;
42
+ } | {
43
+ op: "decideAsk";
44
+ askId: string;
45
+ batchId: string;
46
+ decision: DecideAskInput;
47
+ } | {
48
+ op: "claimTerminal";
49
+ askId: string;
50
+ batchId: string;
51
+ intent: AskTerminalClaimIntent;
52
+ } | {
53
+ op: "expireAsk";
54
+ askId: string;
55
+ batchId: string;
56
+ } | {
57
+ op: "bindBatch";
58
+ batchId: string;
59
+ askId: string;
60
+ gate: BindGateInput;
61
+ } | {
62
+ op: "abortBatch";
63
+ batchId: string;
64
+ } | {
65
+ op: "deferReconcile";
66
+ askId: string;
67
+ expectedState: AskState;
68
+ expectedRev: number;
69
+ nowMs: number;
70
+ } | {
71
+ op: "resolveProvisional";
72
+ askId: string;
73
+ from: AskState;
74
+ to: AskState;
75
+ patch: AskTransitionPatch;
76
+ } | {
77
+ op: "deleteByTask";
78
+ taskId: string;
79
+ };
80
+ export declare class FileApprovalAskStore implements ApprovalAskStore {
81
+ /** 语义内核(转移判定的唯一真源)。本类对它只做两件事:重放、代理。 */
82
+ private readonly core;
83
+ private readonly path;
84
+ private readonly tmpDir;
85
+ private log;
86
+ /** 活文件里的记录数(重放读数 + 每次 append,压实后归为快照行数)。压实判据读它。 */
87
+ private records;
88
+ /**
89
+ * 钉住的钟(见 {@link InMemoryApprovalAskStore} 顶注的读钟纪律)。非 null 的窗口**恒是同步的** ——
90
+ * 从 append 之后到内核方法返回 promise 为止,其间没有任何 `await` ⇒ 两次并发调用不可能互相看见对方的钉。
91
+ */
92
+ private pinnedNow;
93
+ /** 重放期被内核拒掉的记录数(每条都已 warn;读面留给测试与将来的诊断)。 */
94
+ private replayRejectedCount;
95
+ /** 重放期被丢弃的坏行数(撕尾不计:那是本格式存在的理由,由 {@link corruptQuarantinePath} 单独说)。 */
96
+ private corruptLineCount;
97
+ /** 本次启动隔离出去的原件路径(无坏字节 ⇒ `undefined`)。 */
98
+ readonly corruptQuarantinePath: string | undefined;
99
+ /**
100
+ * boot 期 fail-stop(File 店族同律,`approval-ask-audit-store.ts` 的同段先例):mkdir / 重放失败
101
+ * **拒启** —— 一台「审批账写不进却照常发卡」的机器,比起不起来危险得多。
102
+ *
103
+ * @param root local 数据根(= `LocalBackend` 的 `FileStorageBackend.root`,零新 env)。
104
+ */
105
+ constructor(root: string);
106
+ /**
107
+ * 内核的钟。窗口外读 ⇒ **抛**(不静默回落 `Date.now()`):窗口外的读数按定义不在账本上,重放会得出
108
+ * 另一个时间戳 —— 那是一条**静默**的重放分岔,而本店的全部价值就是重放等价。今天内核的五个读钟点
109
+ * 全在同步前缀里;哪天有人挪到 `await` 之后,这里当场响。
110
+ */
111
+ private clockRead;
112
+ private logFor;
113
+ /**
114
+ * 一次写:**先 append + fsync,再翻内存**(顶注的崩溃序)。append 与内核调用之间零 `await` ⇒
115
+ * 盘上的记录序 == 内存的转移序(内核的写体全在同步前缀里),两副读数永远对得上。
116
+ *
117
+ * 🔴 **内核吃的是「盘上那一份」,不是调用方那一份**(替补对抗复审 F7,采纳):整条调用先过一次 JSON
118
+ * round-trip,再**把 round-trip 的产物**同时交给账本与内核。不这么做的话,凡是 JSON round-trip 会改形
119
+ * 的值(`Date` → ISO 串、函数值键被**静默丢掉**、`undefined` 键消失)都会造出一条真分岔:活路径上内核
120
+ * 手里是原对象、重启重放之后是另一份 —— 而这正是本店整个形所依赖的那条等式。首版只为 `updatedInput`
121
+ * 一格做了这件事(它另有「编出来什么都不是 ⇒ 响亮拒」的三态判据),其余键(`cardJson` / `patch` /
122
+ * `gate` / `decisionActor`)留了一条没写出来的例外。一条规则覆盖所有键,例外消失。
123
+ * 代价如实:不可编码的值(BigInt / 循环引用)在本形上**抛**,而 InMemory 形收得下 —— 那与 SQL 孪生
124
+ * 同向(它也存不下),是**介质事实**,写进顶注的边界段。
125
+ *
126
+ * 🔴 **压实失败不许落到调用方头上**(替补对抗复审 F3 [medium],实测后修):走到 `maybeCompact()` 时这
127
+ * 一次写**已经 fsync 落盘、也已经翻进内存**,把一次纯管家动作的 I/O 错误抛给调用方 = 把一次已生效的
128
+ * 人类批准报成失败(客户端重试会拿到 `ask_not_pending`)。压实是 F 类兜底:失败留痕、下一次写再试。
129
+ */
130
+ private write;
131
+ private maybeCompact;
132
+ /** 整本重写成行快照 + 原子替换。失败 ⇒ 原账本原样留着、`records` 不动(下一次写再试),日志由
133
+ * core 的 [ref] 惰性重开接住 —— 一次瞬时 I/O 错误绝不把这条日志变成砖。 */
134
+ private compact;
135
+ private replayRecord;
136
+ /** 闭集穷尽派发(漏一口 = 编译红,[ref] 形)。返回内核的 promise —— 结果本身在重放期无人消费。 */
137
+ private replayOp;
138
+ ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
139
+ transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
140
+ /**
141
+ * 🔴 `updatedInput` 的编码必须在 **append 之前**、且走内核**同一只** {@link encodeDecideUpdatedInput}:
142
+ * 账本本身是 JSON,而 `JSON.stringify` 对顶层函数是**静默丢键**不是抛 —— 不先编码就会写出一条
143
+ * 「没带编辑」的记录,而活路径那一侧是抛。编码抛 ⇒ 账本一个字节没写、内核一个字节没动(与内核自己的
144
+ * 「先编码、编码成功才一次性提交」同一条原子性)。
145
+ */
146
+ decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
147
+ claimTerminal(askId: string, batchId: string, intent: AskTerminalClaimIntent): Promise<AskTerminalClaimOutcome>;
148
+ expireAsk(askId: string, batchId: string): Promise<ExpireResult>;
149
+ bindBatch(batchId: string, askId: string, gate: BindGateInput): Promise<BindResult>;
150
+ abortBatch(batchId: string): Promise<string[]>;
151
+ deferReconcile(askId: string, expectedState: AskState, expectedRev: number, nowMs: number): Promise<boolean>;
152
+ resolveProvisional(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
153
+ deleteByTask(taskId: string): Promise<void>;
154
+ listByState(state: AskState, limit: number): Promise<AskRow[]>;
155
+ listPendingByTask(taskId: string, signal?: AbortSignal): Promise<AskRow[]>;
156
+ listPendingBySession(sessionId: string, owner: string | null, signal?: AbortSignal): Promise<AskRow[]>;
157
+ getAsk(askId: string): Promise<AskRow | null>;
158
+ getByIdempotencyKey(taskId: string, key: string): Promise<AskRow | null>;
159
+ getBatch(batchId: string): Promise<BatchRow | null>;
160
+ /** 重放读数(测试与诊断):被内核拒掉的记录数 / 解析不出的坏行数 / 活文件当前的记录数。 */
161
+ replayStats(): {
162
+ rejected: number;
163
+ corruptLines: number;
164
+ records: number;
165
+ };
166
+ /** 释放追加 fd(`LocalBackend.close` → 优雅重启重开)。不包 try/catch 的理由同
167
+ * {@link FileApprovalNonceStore.dispose}:core 的 `AppendLog.close()` 自己就是全函数。 */
168
+ dispose(): void;
169
+ }
170
+ export {};
171
+ //# sourceMappingURL=approval-ask-store-file.d.ts.map
@@ -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
  }
@@ -154,15 +154,16 @@ export interface StoreBackend {
154
154
  * **不能**退化成进程内易失形:一次重启若把记录抹掉,JWT TTL 窗内的重放就复活了,而那正是本店要关的洞。 */
155
155
  approvalNonce(): ApprovalNonceStore;
156
156
  /** [ref]([ref] §3.0-§3.2)流内审批协议持久层:每个流内工具调用一行的 ask 状态机 + 批行。
157
- * REQUIRED on all backends — tidb/pg = SQL 双方言 twin;local = {@link InMemoryApprovalAskStore}。
157
+ * REQUIRED **且持久** on all backends — tidb/pg = SQL 双方言 twin;local = {@link FileApprovalAskStore}。
158
158
  *
159
- * 🔴 local 形的代价必须在这里说清楚(而不是留给读者自己发现):InMemory 的 STREAM_PENDING/DECIDED/
160
- * PARKING 全在两个进程内 Map 里 ⇒ **重启同时丢重放基准、丢已接受的决议、丢审计**——「一次真实的人类
161
- * 批准凭空消失」和「对账便利丢失」不是一个量级。因此协议在 local 车道**默认不上场**:能力面
162
- * (`streamApproval`)的判据带一条「ask 账必须是持久的」合取项,local 不报 true(诚实缺席,同
163
- * `park 设施缺席 ⇒ 协议不上场` 的姿势)。要在 local 真上协议,先落 File 形(现成模子 =
164
- * {@link FileApprovalExemptionStore}:core `AppendLog`/`readJsonlRecords`,内存索引 + 追加日志即可
165
- * 满足单实例 boot-lock 下的 CAS),那时这里换成 File twin、能力面自然翻真。
159
+ * 🔴 **「持久」是本口的契约,不是某条腿的巧合**(S-384):`resolveStreamApprovalGate` 自此**不再**
160
+ * 单独判一次 ask 账的持久性 —— 那条合取项(旧 `volatile_ask_ledger`)存在的唯一理由是 local 车道
161
+ * 当年交的是 `InMemoryApprovalAskStore`(两个进程内 Map ⇒ 重启同时丢重放基准、丢已接受的决议;
162
+ * 「一次真实的人类批准凭空消失」与「对账便利丢失」不是一个量级)。File 形落地后三条腿全是持久店,
163
+ * 那条合取项没有任何可以让它为假的输入 ⇒ 按「修完让规则更少」删掉,持久性的义务搬到**这里**。
164
+ * ⇒ **新加一条 backend 腿时必须给持久店**:进程内易失形会让能力面 `streamApproval` 对消费方撒谎
165
+ * (它承诺的是「稍后还能批、崩了行还在」)。机器钉 = `wiring-governance-operator.test.ts` 的
166
+ * 「三腿 approvalAsk 全持久」格。
166
167
  *
167
168
  * 无 backend 的 env-only worker 压根没有 `StoreBackend` ⇒ 协调器拿到 undefined askStore ⇒ 车2 的
168
169
  * D1 逐字现行为(store 缺席 = 现行 `tool_approval` 活卡腿一字不变)。 */
@@ -6,7 +6,8 @@ import { fileSessionCaptureRecordStore } from "@sema-agent/core";
6
6
  import { TiDBOutcomeLedger, PgOutcomeLedger } from "./outcome-ledger-sql.js";
7
7
  import { FileOutcomeSink } from "./file-outcome-sink.js";
8
8
  import { TiDBWorkflowRunStore, TiDBWorkflowCompletionInbox, TiDBWorkflowNotifyJournalStore, TiDBWorkflowAgentSessionIndex, PgWorkflowRunStore, PgWorkflowCompletionInbox, PgWorkflowNotifyJournalStore, PgWorkflowAgentSessionIndex } from "./workflow-run-store-sql.js";
9
- import { FileRunStore, RunStoreStrictHydrateError } from "./file-run-store.js";
9
+ import { FileRunStore } from "./file-run-store.js";
10
+ import { StoreBootRefusalError } from "./store-contracts.js";
10
11
  import { NO_SQL_MIGRATIONS, runSqlOneTimeMigrations } from "./one-time-migrations.js";
11
12
  import { LocalSessionStore } from "./local-session-store.js";
12
13
  import { LocalCheckpointStore } from "./local-checkpoint-store.js";
@@ -14,7 +15,7 @@ import { FileResumeAnchorStore } from "./file-resume-anchor-store.js";
14
15
  import { TiDBApprovalExemptionStore, PgApprovalExemptionStore, FileApprovalExemptionStore } from "./approval-exemption-store.js";
15
16
  import { TiDBApprovalNonceStore, PgApprovalNonceStore, FileApprovalNonceStore } from "./approval-nonce-store.js";
16
17
  import { TiDBApprovalAskStore, PgApprovalAskStore } from "./approval-ask-store-sql.js";
17
- import { InMemoryApprovalAskStore } from "./approval-ask-store-memory.js";
18
+ import { FileApprovalAskStore } from "./approval-ask-store-file.js";
18
19
  import { TiDBMemoryOptOutGrantStore, PgMemoryOptOutGrantStore } from "./memory-optout-grant-store-sql.js";
19
20
  import { TiDBSessionCaptureRecordStore, PgSessionCaptureRecordStore } from "./session-capture-record-store-sql.js";
20
21
  import { TiDBSendFileLedger, PgSendFileLedger, FileSendFileLedger } from "./send-file-ledger.js";
@@ -204,7 +205,7 @@ class LocalBackend {
204
205
  resumeAnchorStore;
205
206
  approvalExemptionStore;
206
207
  approvalNonceStore;
207
- approvalAskStore = new InMemoryApprovalAskStore();
208
+ approvalAskStore;
208
209
  sendFileLedgerStore;
209
210
  workflowJournalStore;
210
211
  outcomeSinkInst;
@@ -243,6 +244,7 @@ class LocalBackend {
243
244
  this.resumeAnchorStore = new FileResumeAnchorStore(this.fileBackend.root);
244
245
  this.approvalExemptionStore = new FileApprovalExemptionStore(this.fileBackend.root);
245
246
  this.approvalNonceStore = new FileApprovalNonceStore(this.fileBackend.root);
247
+ this.approvalAskStore = new FileApprovalAskStore(this.fileBackend.root);
246
248
  this.sendFileLedgerStore = new FileSendFileLedger(this.fileBackend.root);
247
249
  this.workflowJournalStore = new FileWorkflowJournalStore(this.fileBackend.root);
248
250
  this.outcomeSinkInst = new FileOutcomeSink(this.fileBackend.root);
@@ -261,6 +263,7 @@ class LocalBackend {
261
263
  this.resumeAnchorStore.dispose();
262
264
  this.approvalExemptionStore.dispose();
263
265
  this.approvalNonceStore.dispose();
266
+ this.approvalAskStore.dispose();
264
267
  this.sendFileLedgerStore.dispose();
265
268
  this.workflowJournalStore.dispose();
266
269
  this.checkpointStoreInst?.close();
@@ -336,7 +339,7 @@ export async function openStoreBackendWithFallback(config, logger) {
336
339
  catch (err) {
337
340
  if (err instanceof AdoptionError)
338
341
  throw err;
339
- if (err instanceof RunStoreStrictHydrateError)
342
+ if (err instanceof StoreBootRefusalError)
340
343
  throw err;
341
344
  if (!mayFallback)
342
345
  throw err;
@@ -15,6 +15,26 @@
15
15
  * code should import from this file.
16
16
  */
17
17
  import type { TaskResult, TaskStatus } from "@sema-agent/core";
18
+ /**
19
+ * **File 店族的 boot 期拒启基类**(S-384;把 `RunStoreStrictHydrateError` 的那条论证升成一条族规)。
20
+ *
21
+ * 🔴 **为什么必须是一个可判别的类型,而且必须是一个共同基类**:`openStoreBackendWithFallback` 对
22
+ * **默认推导**的 local(`DB_BACKEND` 未设 —— 也就是绝大多数单机部署)有一条既有降级臂:mkdir / 只读盘
23
+ * 那类「这台机器存不了盘」的失败降级到内存继续起。而 `LocalBackend` 构造器里的每一只 File 店都可能抛出
24
+ * 一条**完全不同**的句子——「这份数据有主 / operator 要了 fail-closed / 这份账本我重放不了」——它们从那个
25
+ * catch 看长得一模一样。不给身份 ⇒ 被同一条降级臂吞掉:一次**明确的 fail-closed 表态被翻译成 fail-open**,
26
+ * 整只 `StoreBackend` 被丢掉(持久 run 账本、`/v1/approvals`、park 全没),而唯一的痕迹是一行
27
+ * 「DB 不可达,已退回内存」把运维支去查连通性。
28
+ *
29
+ * 此前这条纪律是**逐个 `if instanceof` 手工登记**的(`AdoptionError` / `RunStoreStrictHydrateError`),
30
+ * 于是「下一只会拒启的 File 店」默认是**漏的** —— S-384 的 ask 账本拒启就这么漏了一次(替补对抗复审
31
+ * F1 [high] 实测:`DB_BACKEND` 未设时坏账本 ⇒ `{backend: undefined, degraded: true}`,不是拒启)。
32
+ * 收法:**一个基类,一条重抛**。新 File 店的拒启只要 `extends StoreBootRefusalError` 就自动落在正确的
33
+ * 一侧;忘了继承是**可见**的(它是一个必须显式选择的父类),而忘了往 if 链里加一行是不可见的。
34
+ * (core 的 `AdoptionError` 不在本仓,改不了它的继承链 ⇒ 降级臂保留它那一条 `instanceof`,共两条。)
35
+ */
36
+ export declare class StoreBootRefusalError extends Error {
37
+ }
18
38
  /**
19
39
  * 取消便签里的**大脑相位**快照 —— core `BrainStatus` 帧经 `brainStatusEventData` 投影后的五键子集。
20
40
  *
@@ -1,4 +1,6 @@
1
1
  import { parseJsonOr, toIso as iso, normalizeIsoOrNull as isoOrNull } from "./sql-row-helpers.js";
2
+ export class StoreBootRefusalError extends Error {
3
+ }
2
4
  export const RUN_STATUS_CLASS = {
3
5
  running: "live",
4
6
  completed: "succeeded",
@@ -574,24 +574,29 @@ export type UnattendedApprovalPolicy = "park" | "deny";
574
574
  * 于是「能力面说 true」⟺「协调器真拿到了 askStore」⟺「回决端点真有账可 CAS」是**结构成立**的,
575
575
  * 不再靠三处注释互相提醒。
576
576
  *
577
- * 五个合取项,缺一即不上场(每一项的缺席都有它自己的 `reason`,供启动期 info 与诊断分辨):
577
+ * 四个合取项,缺一即不上场(每一项的缺席都有它自己的 `reason`,供启动期 info 与诊断分辨):
578
578
  * 1. `toolApprovalEnabled` —— 连活卡腿都没有,谈不上流内协议;
579
579
  * 2. `streamApprovalEnabled` —— 协议总开关(`STREAM_APPROVAL_ENABLED`,树上已**默认 ON**;显式 `false`
580
580
  * 是唯一干净还原键。默认极性与版本坐标的属主口径见 `config.ts` 的 `streamApprovalConfig()` 头注);
581
581
  * 3. `backend` 在场 —— env-only worker 没有 `StoreBackend` 本体;
582
- * 4. **账必须是持久的**(`kind !== "local"`)—— InMemory 形重启即丢**已接受的决议**,那与「对账便利
583
- * 丢失」不是一个量级(§2.2(b));File 形 ask 店落地后这一项自然翻真;
584
- * 5. **park 设施在场**(§8.4)—— 缺席时「窗到期 ⇒ unavailable」在 core 侧没有降级目的地,结局是
582
+ * 4. **park 设施在场**(§8.4)—— 缺席时「窗到期 ⇒ unavailable」在 core 侧没有降级目的地,结局是
585
583
  * fail-closed deny,**比现状(5min 活卡、人能批)更差**。所以自检不满足 ⇒ **协议不上场**、现行
586
584
  * `tool_approval` 活卡腿逐字保留,而不是「把 ask 推向一个不存在的目的地」(§14 属主照准,core [ref]
587
585
  * 回帖确认即原意)。
586
+ *
587
+ * 🔴 **S-384:第 5 项(旧第 4 项 `volatile_ask_ledger` = 「账必须是持久的」,判据 `kind !== "local"`)
588
+ * 已整条删除,词也从闭集里删了(零别名)**。它当年成立的唯一输入是 local 车道交的 `InMemoryApprovalAskStore`;
589
+ * `FileApprovalAskStore` 落地后 `StoreBackend.approvalAsk()` 的三条腿全是持久店 ⇒ 这条合取项**没有任何
590
+ * 能让它为假的输入**,留着就是一条永不触发的规则(「修完让规则集更小」)。持久性的义务搬到
591
+ * `StoreBackend.approvalAsk()` 的接口契约上(那条注 + `wiring-governance-operator.test.ts` 的三腿机器钉)——
592
+ * 它是**装配面**的事实,而本谓词能看见的只有 `kind`,拿 `kind` 当持久性的代理正是这次要拆掉的那层间接。
588
593
  */
589
594
  export type StreamApprovalGate = {
590
595
  active: true;
591
596
  askStore: ApprovalAskStore;
592
597
  } | {
593
598
  active: false;
594
- reason: "no_tool_approval" | "protocol_disabled" | "no_backend" | "volatile_ask_ledger" | "no_park_facility";
599
+ reason: "no_tool_approval" | "protocol_disabled" | "no_backend" | "no_park_facility";
595
600
  };
596
601
  /** {@link resolveStreamApprovalGate} 的入参。`backend` 用**结构形**(不 import `StoreBackend`):本模块是
597
602
  * 协调器的家,不该为一个布尔判据把整棵 store 依赖树拖进类型面。 */
@@ -656,7 +661,7 @@ export interface ApprovalLegAssembly {
656
661
  * 🔴 **[ref] 起它与 `active` 解耦**(旧文「`active` 为假时恒 false —— 协议都没上场,谈不上关窗」已
657
662
  * 作废):`STREAM_ASK_WINDOW_MS` 是**部署级**旋钮,`0` 的语义(运维显式关窗 ⇒ 恒走
658
663
  * {@link ToolApprovalCoordinator.unattendedAskOutcome},连卡都不发)在两条车道上必须是同一个意思。
659
- * 旧形下 local 车道(`volatile_ask_ledger`)/ 无 park 设施 / 显式关协议的部署配了 `0`,拿到的是一张
664
+ * 旧形下 local 车道(S-384 之前的「账不持久」臂)/ 无 park 设施 / 显式关协议的部署配了 `0`,拿到的是一张
660
665
  * 等满 5min 的活卡 —— 与它自己的域注正好相反,且没有任何一行说出来。
661
666
  * ⚠️ 「窗**缺席**」(`windowMs: undefined` = 这份装配根本没有 streamApproval 段,只出现在 stub-harness)
662
667
  * **不是**关窗:那时逐字保持活卡腿。 */
@@ -167,8 +167,6 @@ export function resolveStreamApprovalGate(input) {
167
167
  return { active: false, reason: "protocol_disabled" };
168
168
  if (!input.backend)
169
169
  return { active: false, reason: "no_backend" };
170
- if (input.backend.kind === "local")
171
- return { active: false, reason: "volatile_ask_ledger" };
172
170
  if (!input.parkFacility)
173
171
  return { active: false, reason: "no_park_facility" };
174
172
  return { active: true, askStore: input.backend.approvalAsk() };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.80.2",
3
+ "version": "7.81.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",