@sema-agent/server 7.75.4 → 7.76.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
@@ -755,21 +755,37 @@ A2A_SERVE_BLOCKING_WAIT_MS=45000 # blocking:true 的服务端等待
755
755
  SEMA_PARENT_PID=$$ # 壳把自己的 pid 传给它 spawn 出来的引擎
756
756
  ```
757
757
 
758
- - **缺席 = 整件不装配**(opt-in)。不设 = 和以前**逐字节一致**,存量部署零变化。
759
- - **探活** = `process.kill(pid, 0)`(不投递任何信号,只问"这个 pid 在不在")。`ESRCH` = 父不在;
760
- **`EPERM` = 父还活着**(进程在,只是本进程无权给它发信号 —— 父跑在另一个 uid 下时很常见)。
761
- - **连续两拍**(拍频固定 **15s**)都是 `ESRCH` 才判死,单拍抖动不误杀。判死后走的是**和 `SIGTERM`
762
- 逐字同一条**优雅排空腿(`DRAIN_GRACE_MS` 那条):in-flight 的 run 照常结算 / park,不是裸退出。
763
- - **拍频不开旋钮**(15s 写死)。有真需求再议 —— 这里刻意不增殖一个只有一个人会调的键。
764
- - 日志:装配时一行 `parent_watch_armed`(带 `pid`),自退时一行 `parent_watch_exit`(带两拍的时间戳)。
758
+ `DB_BACKEND=local` 上还多一层痛:那只孤儿抱着 `<数据根>/LOCK` 不放(判定持锁人是否还在用的就是
759
+ `process.kill(pid,0)`,所以**活着的孤儿是合法持锁人**),于是此后每一只新壳起自己的引擎都撞
760
+ `another instance (pid N) owns this data dir` —— 用户看到的是"壳崩过一次以后就再也起不来了"。
761
+
762
+ - **缺席 = 整件不装配**(opt-in)。不设 = 和以前**逐字节一致**,存量部署零变化。systemd / launchd /
763
+ 容器托管的引擎本来就 reparent 到 1,**不要**给它们设这个键。
764
+ - **判死 = 两支,任一成立即判死**(拍频固定 **2s**):
765
+ - **被过继走**(强身份):`process.ppid` 不再是引擎启动那一刻的那个值 —— 生我的进程没了,内核就把我
766
+ 过继给 init(或容器里的 subreaper)。判据刻意**不是** `ppid == 1`:收养者未必是 1,而且壳自己就是
767
+ pid 1 的容器形里 `== 1` 会在开机第一拍误杀一台健康引擎。
768
+ - **pid 不存在**(弱身份):`process.kill(pid, 0)` 抛 `ESRCH`(不投递任何信号,只问"这个 pid 在不在")。
769
+ **`EPERM` = 父还活着**(进程在,只是本进程无权给它发信号 —— 父跑在另一个 uid 下时很常见)。
770
+ - **单拍即判,不去抖**:两支读的都是内核直答,不是会抖的采样值。判死后走的是**和 `SIGTERM` 逐字同一条**
771
+ 优雅排空腿(`DRAIN_GRACE_MS` 那条):in-flight 的 run 照常结算 / park,不是裸退出;收尾链里的
772
+ `backend.close()` 才是把 `LOCK` 真正还回去的那一步。**实测**:壳挨 `kill -9` 到引擎退干净(退出码 0)
773
+ 且 `LOCK` 消失 ≈ **2s**,部署面按 **≤5s** 用。
774
+ - **拍频不开旋钮**(2s 写死)。有真需求再议 —— 这里刻意不增殖一个只有一个人会调的键。
775
+ - 日志:装配时一行 `parent_watch_armed`(`info`,带 `pid` / `ppidAtArm` / `intervalMs`),判死时一行
776
+ `parent_watch_exit`(**`warn`** —— 非计划内的停机,带 `reason: "reparented" | "esrch"`),随后就是那条
777
+ `draining_started`。
765
778
  - 坏值(`0` / 负数 / 小数 / 非数字 / 超过 `2147483647`)**拒启并指路**:`0` 和负数在 `process.kill` 里的
766
779
  语义是**进程组**,超出 int32 的值 `process.kill` 根本收不下(会变成"armed 了但永远探不动")。要关掉就
767
- **不设**这个键。探活若给出既不是 `ESRCH` 也不是 `EPERM` 的 errno,会打一条(仅一条)
768
- `parent_watch_probe_error` 并按"父还活着"处理 —— 监视此时是失效的,那行日志就是它唯一的告警。
769
- - 🔴 **已知残余(不收窄,两条)**:① 两拍窗口(≤30s)内父 pid 被系统**复用**时,探活看到的是新进程,
770
- 监视判"父还活着"而不自退;② 父进程退出后成了**僵尸**(它自己的上级不回收子进程 —— 容器里的朴素
771
- PID 1 是典型;终端 / launchd / systemd 不在此列),pid 表项仍在,探活同样判"活着"。两条同源:
772
- `kill(pid, 0)` 是**弱身份**判据。这条腿是**尽力自愈**,不是强一致的父子生命周期绑定。
780
+ **不设**这个键。探活若给出既不是 `ESRCH` 也不是 `EPERM` 的 errno,会打一条(仅一条)**`warn` 档**
781
+ `parent_watch_probe_error` 并按"父还活着"处理 —— 弱身份那一支此时是失效的(强身份支不受影响),那行
782
+ 日志就是它唯一的告警。
783
+ - 🔴 **已知残余(不收窄,两条)**:① 引擎**还在 boot**(监视腿装在 `listen()` 之后)时父就死了**且**成了
784
+ **僵尸**(它自己的上级不回收子进程 —— 容器里的朴素 PID 1 是典型;终端 / launchd / systemd 不在此列)
785
+ —— 强身份支没有"变化"可看,弱身份支被僵尸的 pid 表项骗住,两支都不触发;② 壳经一层 **wrapper** spawn
786
+ 引擎却仍传自己的 pid 时,wrapper 先退会让强身份支判死一台壳还活着的引擎(约定是**壳直接 spawn 引擎**,
787
+ 别把这个键设成非直接父的 pid)。这条腿是**尽力自愈**,不是强一致的父子生命周期绑定 —— 要强一致,该由
788
+ 壳侧开一条持有型管道并在断开时收尸。
773
789
 
774
790
  **可选 — 附着租约自退(`ENGINE_AUTO_EXIT`,默认关)**
775
791
 
@@ -20,7 +20,7 @@ import type { Logger } from "../observability/logger.js";
20
20
  import type { Metrics } from "../observability/metrics.js";
21
21
  import type { WorkflowNotifyGate, WorkflowNotifyJournalStore } from "../orchestration/workflow-notify-journal.js";
22
22
  import type { TaskAttachmentStore } from "../plugins/task-attachment-store.js";
23
- import type { CheckpointStoreFull, CostQuotaStore, ImageBake, RateLimiterStore, ServiceWorkflowJournalStore, StoreBackend, ToolResultStoreFull } from "../plugins/store-backend.js";
23
+ import type { ApprovalNonceStore, CheckpointStoreFull, CostQuotaStore, ImageBake, RateLimiterStore, ServiceWorkflowJournalStore, StoreBackend, ToolResultStoreFull } from "../plugins/store-backend.js";
24
24
  import type { ToolApprovalCoordinator } from "../tool-approval.js";
25
25
  /** [ref] / [ref]§五(sema-comms `audits/failopen-governance-176.md` §7 "Z 类 1 条"):default consecutive-failure
26
26
  * count before a reaper leg's swallowed error gets a `warn` line. The ~15 `.catch(() => undefined)` sites
@@ -96,6 +96,10 @@ export interface ReapersCtx {
96
96
  * (`boot/permission-rules-audit.ts` 的 `DormantRuleCounter` 是同一条判据的先例)。
97
97
  * 生产传的仍是整只束(结构上满足这个窄口)。 */
98
98
  permissionRuleStores: PermissionRuleRetentionSweeper | undefined;
99
+ /** S-280 直连门 nonce 的消费记录店 —— 本 tick 只用它的**到期清扫**一手。行至多活 ~150 秒(= 证明还能被
100
+ * 受理的最后一刻),所以存量是「到达率 × TTL」而不是历史总量,清扫无需分批。无 backend 的 env-only
101
+ * 部署 ⇒ undefined ⇒ 本腿零调用(那种部署也没有 durable 审批门)。 */
102
+ approvalNonceStore: ApprovalNonceStore | undefined;
99
103
  /** 晚绑(server 造出来才有)——见文件头「位置即契约」①。 */
100
104
  getRunDenySweep: () => ((now: number) => Promise<void>) | undefined;
101
105
  }
@@ -56,7 +56,7 @@ export function createPlacementAwareReapSessions(deps) {
56
56
  };
57
57
  }
58
58
  export function startReapers(ctx) {
59
- const { config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore, rateLimiter, costQuota, toolResultStore, fileHistoryStore, taskAttachmentStore, imageBakes, worktreeReap, workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore, backgroundAgentStore, mailboxStore, toolApproval, permissionRuleStores, getRunDenySweep, } = ctx;
59
+ const { config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore, rateLimiter, costQuota, toolResultStore, fileHistoryStore, taskAttachmentStore, imageBakes, worktreeReap, workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore, backgroundAgentStore, mailboxStore, toolApproval, permissionRuleStores, approvalNonceStore, getRunDenySweep, } = ctx;
60
60
  const leg = (p) => {
61
61
  if (p !== undefined)
62
62
  trackMaintenanceLeg(p);
@@ -80,6 +80,7 @@ export function startReapers(ctx) {
80
80
  const runsReapStaleGuard = createThrottledReaperCatch("runs_reap_stale", logger);
81
81
  const e2bOrphanGuard = createThrottledReaperCatch("e2b_orphan_reclaim", logger);
82
82
  const checkpointsReapExpiredGuard = createThrottledReaperCatch("checkpoints_reap_expired", logger);
83
+ const approvalNonceReapExpiredGuard = createThrottledReaperCatch("approval_nonce_reap_expired", logger);
83
84
  const runsFailSuspendedExpiredCheckpointGuard = createThrottledReaperCatch("runs_fail_suspended_expired_checkpoint", logger);
84
85
  const runDenySweepGuard = createThrottledReaperCatch("run_deny_sweep", logger);
85
86
  const runsReapSuspendedGuard = createThrottledReaperCatch("runs_reap_suspended", logger);
@@ -208,6 +209,10 @@ export function startReapers(ctx) {
208
209
  leg(getRunDenySweep()?.(Date.now())
209
210
  .then(() => runDenySweepGuard.onSuccess(), runDenySweepGuard.onError));
210
211
  }
212
+ leg(approvalNonceStore
213
+ ?.reapExpired()
214
+ .then(reapCount("approval_nonces_reaped_total", {}))
215
+ .then(() => approvalNonceReapExpiredGuard.onSuccess(), approvalNonceReapExpiredGuard.onError));
211
216
  if (config.approvalTimeoutSec > 0) {
212
217
  leg(runStore
213
218
  ?.reapSuspended(config.approvalTimeoutSec * 1000)
@@ -143,11 +143,15 @@ export function installShutdownHandlers(ctx) {
143
143
  });
144
144
  process.on("SIGHUP", onSighup);
145
145
  if (config.parentPid !== undefined) {
146
+ const parentWatchSink = {
147
+ info: (event, fields) => logger.info(event, fields),
148
+ warn: (event, fields) => logger.warn(event, fields),
149
+ };
146
150
  parentWatch = createParentWatch({
147
151
  pid: config.parentPid,
148
152
  isStopped: () => closing || draining,
149
153
  drain: drainThenShutdown,
150
- log: (event, fields) => logger.info(event, fields),
154
+ log: (level, event, fields) => parentWatchSink[level](event, fields),
151
155
  });
152
156
  }
153
157
  if (config.engineAutoExit) {
@@ -380,7 +380,7 @@ export const CONFIG_CATALOG = [
380
380
  r("SEMA_FAILOPEN_PROBE", "observability", "path", "F 类 fail-open 命中的探针文件路径(在场时逐次追加 fail-open 行;缺席只走计数+一次性 warn)"),
381
381
  r("SEMA_NODE_MIRROR", "orchestration", "url", "SANDBOX_PKG_SOURCE=custom 的显式 node 分发镜像地址"),
382
382
  r("SEMA_NPM_REGISTRY", "orchestration", "url", "SANDBOX_PKG_SOURCE=custom 的显式 npm registry 地址"),
383
- r("SEMA_PARENT_PID", "limitsHttp", "number", "引擎 parent 监视(壳 pid;连续两拍 ESRCH 判死走 drain 腿;0/负数语义完全不同 ⇒ 拒启)", { derivedDefaultNote: "缺席 ⇒ 整件不装配", configKey: "parentPid" }),
383
+ r("SEMA_PARENT_PID", "limitsHttp", "number", "引擎 parent 监视(壳 pid;被过继走或该 pid 不存在即判死,走 SIGTERM 同一条 drain 腿;0/负数语义完全不同 ⇒ 拒启)", { derivedDefaultNote: "缺席 ⇒ 整件不装配", configKey: "parentPid" }),
384
384
  r("SEMA_PIP_INDEX", "orchestration", "url", "SANDBOX_PKG_SOURCE=custom 的显式 pip index 地址"),
385
385
  r("SEMA_REGISTRY_ADMIN_TOKEN", "integrations", "secret", "写 center 的管理凭证(仅 boot 上报执行公钥用;刻意不入 ServiceConfig —— 不进 configHash/日志面)", { danger: SEC, scope: "bootstrap-only" }),
386
386
  r("SEMA_REGISTRY_DRY_RUN", "integrations", "boolean", "center 腿 dry-run(拉而不应用)", { staticDefault: "false" }),
@@ -1492,16 +1492,18 @@ export interface ServiceConfigFlat {
1492
1492
  * T11 stands, just bounded instead of immediate. A SECOND SIGHUP (or SIGTERM) escalates to the normal drain
1493
1493
  * path. Default 120s, floor 5s. Env: SIGHUP_IDLE_GRACE_MS. */
1494
1494
  sighupIdleGraceMs: number;
1495
- /** [ref](黑板 [ref]):引擎 **parent 监视**。壳自 spawn 引擎的同机形里,壳被 SIGKILL(崩溃/强杀,
1495
+ /** [ref](判据 S-292 改版):引擎 **parent 监视**。壳自 spawn 引擎的同机形里,壳被 SIGKILL(崩溃/强杀,
1496
1496
  * 没有任何清理钩子)后引擎被 reparent 到 init 而**继续活着** —— 孤儿引擎占端口、占库连接、占模型
1497
- * 配额,而且再没有人会给它发 SIGTERM(T11 的进程组腿只覆盖信号送得到的形)。设了本键 = 引擎每 15s
1498
- * 用 `process.kill(pid, 0)` 探一拍这个 pid,**连续两拍** ESRCH(父不在)才判死;判死后走与 SIGTERM
1497
+ * 配额、抱着 `LOCAL_DATA_ROOT/LOCK`(活孤儿是合法持锁人 ⇒ 下一只壳的引擎必撞锁),而且再没有人会给
1498
+ * 它发 SIGTERM(T11 的进程组腿只覆盖信号送得到的形)。设了本键 = 引擎每 2s 自查一拍,**两支任一成立
1499
+ * 即判死**:① `process.ppid` 不再是启动那一刻的值(被过继走,强身份);② `process.kill(pid, 0)` 抛
1500
+ * `ESRCH`(弱身份)。`EPERM` = 进程在、只是本进程无权给它发信号 ⇒ **算活着**。判死后走与 SIGTERM
1499
1501
  * **逐字同一条** graceful drain 腿(in-flight run 的结算 / park 语义与人工停机一致),不是裸
1500
- * `process.exit`。`EPERM` = 进程在、只是本进程无权给它发信号 ⇒ **算活着**。
1502
+ * `process.exit`。
1501
1503
  * **缺席 ⇒ 整件不装配**(opt-in;存量部署逐字零变化);**非正整数 ⇒ 拒启**(带指路文案)。
1502
- * 拍频固定 15s,不开 interval 旋钮(有真需求再议,防旋钮增殖)。
1503
- * 已知残余(成文,不收窄):两拍窗口内 pid 被系统复用 ⇒ 探活看到的是**新**进程,监视判"父还活着"
1504
- * 而不自退。监视是**尽力自愈**、不是强一致的父子生命周期绑定。 Env: `SEMA_PARENT_PID`。 */
1504
+ * 拍频固定 2s,不开 interval 旋钮(有真需求再议,防旋钮增殖)。
1505
+ * 已知残余(成文,不收窄)见 `src/parent-watch.ts` 头注(boot 窗内的僵尸父 / 中间 wrapper 进程两形)。
1506
+ * 监视是**尽力自愈**、不是强一致的父子生命周期绑定。 Env: `SEMA_PARENT_PID`。 */
1505
1507
  parentPid?: number;
1506
1508
  /** [ref](黑板 [ref]②):引擎**附着租约自退**总开关。壳自 spawn 引擎的本机形里,壳退出之后引擎
1507
1509
  * 继续活着 = 占端口/占库连接/占模型配额的孤儿。设了本键 = 引擎每 15s 自查一次「还有没有人附着」,
@@ -7,6 +7,7 @@ import { isParkedRunStatus } from "../../plugins/store-contracts.js";
7
7
  import { principalFrom, decodeCheckpointScope, PRINCIPAL_TOKEN_HEADER, APPROVAL_MAC_HEADER, APPROVAL_MAC_KID_HEADER } from "../../security.js";
8
8
  import { verifyDirectDoorProof } from "../../principal-jwt.js";
9
9
  import { MAX_APPROVAL_REASON_CHARS } from "../../approval-hmac.js";
10
+ import { MAX_APPROVAL_NONCE_CHARS } from "../../plugins/approval-nonce-store.js";
10
11
  import { redactedPreview, redactSecrets } from "../../trace/redact.js";
11
12
  import { redactRiskDescriptor } from "../../observability/run-terminal-log.js";
12
13
  import { fleetRunLabels } from "../../fleet/fleet-bus.js";
@@ -363,6 +364,21 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
363
364
  sendError(res, 413, "reason_too_large", `reason too large (max ${MAX_APPROVAL_REASON_CHARS} chars)`);
364
365
  return;
365
366
  }
367
+ if (body.boundCallId !== undefined) {
368
+ if (typeof body.boundCallId !== "string" || body.boundCallId.length === 0) {
369
+ sendError(res, 400, "request.field_invalid", "boundCallId must be a non-empty string (the single-use nonce the signer folded into cnf.bnd and the HMAC envelope)");
370
+ return;
371
+ }
372
+ if (body.boundCallId.length > MAX_APPROVAL_NONCE_CHARS) {
373
+ sendError(res, 400, "request.field_invalid", `boundCallId too large (max ${MAX_APPROVAL_NONCE_CHARS} chars)`);
374
+ return;
375
+ }
376
+ if (!deps.config.directDoorActive) {
377
+ sendError(res, 400, "request.field_conflict", "boundCallId (the plan_review nonce) is only valid on a direct-door worker — a BFF worker carries no decision proof to bind it to");
378
+ return;
379
+ }
380
+ }
381
+ const planNonce = typeof body.boundCallId === "string" ? body.boundCallId : undefined;
366
382
  const run = await deps.runStore.getRun(taskId);
367
383
  if (!run) {
368
384
  if (deps.config.directDoorActive) {
@@ -374,14 +390,22 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
374
390
  return;
375
391
  }
376
392
  let deciderPrincipal = principal;
393
+ let singleUse;
377
394
  if (deps.config.directDoorActive) {
395
+ const store = deps.approvalNonceStore;
396
+ if (store === undefined) {
397
+ sendError(res, 501, "capability.store_required", "plan_review on a direct-door worker requires a durable approval-nonce store (DB_BACKEND=mysql|pg|local) — without it the single-use proof nonce cannot be enforced, and this worker will not accept a replayable proof");
398
+ return;
399
+ }
400
+ const storeNowMs = await store.nowMs();
378
401
  const hdr = (n) => { const h = req.headers[n]; return Array.isArray(h) ? h[0] : h; };
379
- const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) }, { sessionId: run.sessionId, decision, reason: typeof body.reason === "string" ? body.reason : null }, deps.config, { actionBinding: false });
402
+ const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) }, { sessionId: run.sessionId, boundCallId: planNonce ?? null, decision, reason: typeof body.reason === "string" ? body.reason : null }, deps.config, { actionBinding: false, nowSec: Math.floor(storeNowMs / 1000) });
380
403
  if (!proof.ok) {
381
404
  sendError(res, proof.status, proof.errorCode, proof.error);
382
405
  return;
383
406
  }
384
407
  deciderPrincipal = proof.principal;
408
+ singleUse = proof.singleUse === undefined ? undefined : { proof: proof.singleUse, store };
385
409
  }
386
410
  const operators = deps.config.operatorPrincipals;
387
411
  const explicitOperator = explicitOperatorOk(deciderPrincipal, operators);
@@ -390,6 +414,18 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
390
414
  sendError(res, 404, "not_found.run", "task not found");
391
415
  return;
392
416
  }
417
+ if (singleUse !== undefined) {
418
+ const { proof: onceOnly, store } = singleUse;
419
+ const fresh = await store.consume(run.sessionId, onceOnly.nonce, onceOnly.acceptedBeforeMs);
420
+ if (!fresh) {
421
+ sendError(res, 409, "approval_replayed", "this decision proof has already been used (its single-use nonce is spent) — mint a fresh nonce and re-sign; a replayed plan_review is never applied");
422
+ return;
423
+ }
424
+ if ((await store.nowMs()) >= onceOnly.acceptedBeforeMs) {
425
+ sendError(res, 401, "principal_unverified", "principal token stopped being acceptable while this decision was being recorded — re-sign with a fresh nonce and retry");
426
+ return;
427
+ }
428
+ }
393
429
  const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req, { taskId, principalPresent: principal !== undefined }, true, { principal: deciderPrincipal, explicitOperator });
394
430
  sendResumeOutcome(res, out, deps.logger);
395
431
  return;
@@ -5,7 +5,7 @@ import type { TaskRequestBody } from "./wire-types.js";
5
5
  import type { ServiceConfig } from "../config-types.js";
6
6
  import { type RestartSignal, type SessionMirrorRuling } from "../config-center/facade.js";
7
7
  import { type OwnerAwareSessionStore } from "../security.js";
8
- import type { RunStore, ImageIndex, ImageBake, CheckpointStoreFull, ResumeAnchorStore, ApprovalExemptionStore, ServiceSessionPolicyStore, ServiceFileHistoryStore, ToolResultStoreFull, StoreBackend } from "../plugins/store-backend.js";
8
+ import type { RunStore, ImageIndex, ImageBake, CheckpointStoreFull, ResumeAnchorStore, ApprovalExemptionStore, ApprovalNonceStore, ServiceSessionPolicyStore, ServiceFileHistoryStore, ToolResultStoreFull, StoreBackend } from "../plugins/store-backend.js";
9
9
  import type { RuleConsentLane } from "../rules-consent.js";
10
10
  import { type MemorySyncRequest, type MemorySyncResponse } from "../memory-sync.js";
11
11
  import type { TaskAttachmentStore } from "../plugins/task-attachment-store.js";
@@ -122,6 +122,10 @@ export interface ServiceStoreDeps {
122
122
  /** Per-session approval exemption store — enables decide body `remember:"session"` + the
123
123
  * exemptions list/revoke surfaces on /v1/approvals. The ask-gate probe is wired separately (main.ts). */
124
124
  approvalExemptionStore?: ApprovalExemptionStore;
125
+ /** S-280:直连门审批证明的**一次性消费记录**(`plan_review` 的 nonce)。每条 store 车道都供,所以在任何
126
+ * `DB_BACKEND` 上都在场;缺席只出现在装配缝(桩 deps)—— 那时直连门的 plan_review **拒**(501,见
127
+ * `routes/approvals-assistant.ts` 的消费点),绝不静默受理一份结构上可重放的证明。BFF 门不读这只店。 */
128
+ approvalNonceStore?: ApprovalNonceStore;
125
129
  /** Durable-checkpoint store ([ref], core 1.67): durable F4 suspend/resume. When present, the resume
126
130
  * endpoint + suspended-run surfacing are active. Set only when DURABLE_APPROVAL + a TiDB pool. */
127
131
  checkpointStore?: CheckpointStoreFull;
package/dist/main.js CHANGED
@@ -368,6 +368,7 @@ async function main() {
368
368
  const runStore = backend ? backend.run() : undefined;
369
369
  const resumeAnchorStore = backend ? backend.resumeAnchor() : undefined;
370
370
  const approvalExemptionStore = backend ? backend.approvalExemption() : undefined;
371
+ const approvalNonceStore = backend ? backend.approvalNonce() : undefined;
371
372
  const taskTimeoutSec = Math.floor(parseNumOrFailNonNegative("TASK_TIMEOUT_SEC", process.env.TASK_TIMEOUT_SEC || "0"));
372
373
  const capEnv = (name) => {
373
374
  const r = parseCapEnv(name, process.env[name]);
@@ -502,7 +503,7 @@ async function main() {
502
503
  config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore,
503
504
  rateLimiter, costQuota, toolResultStore, fileHistoryStore, taskAttachmentStore, imageBakes, worktreeReap,
504
505
  workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore,
505
- backgroundAgentStore, mailboxStore, toolApproval, permissionRuleStores,
506
+ backgroundAgentStore, mailboxStore, toolApproval, permissionRuleStores, approvalNonceStore,
506
507
  getRunDenySweep: () => runDenySweep,
507
508
  });
508
509
  const ttlMs = config.retentionSweep.intervalSec * 1000 * RETENTION_LEASE_TTL_FACTOR;
@@ -605,6 +606,7 @@ async function main() {
605
606
  runStore,
606
607
  resumeAnchorStore: resumeAnchorStore ? resumeAnchorStore : undefined,
607
608
  approvalExemptionStore: approvalExemptionStore ? approvalExemptionStore : undefined,
609
+ approvalNonceStore: approvalNonceStore ? approvalNonceStore : undefined,
608
610
  checkpointStore,
609
611
  sessionPolicyStore: sessionPolicyStore ? sessionPolicyStore : undefined,
610
612
  ruleConsent,
@@ -1,54 +1,86 @@
1
1
  /**
2
- * [ref](黑板 [ref]):引擎 **parent 监视**。
2
+ * 引擎 **parent 监视**([ref] 建座,S-292 改判据)。
3
3
  *
4
4
  * ## 防的是什么
5
5
  *
6
6
  * 壳(cli/TUI/桌面)自 spawn 引擎的**同机形**里,壳被 SIGKILL(崩溃、`kill -9`、OOM killer)之后没有
7
7
  * 任何清理钩子跑得起来:引擎被 reparent 到 init,**继续活着** —— 占端口、占库连接池、占模型配额,而且
8
- * 再没有人会给它发 SIGTERM(T11 的"骑进程组"腿只覆盖信号送得到的形;SIGKILL 那一路本来就无钩)。
9
- * 于是引擎给自己装一只**自查**腿:定期问一句"当年生我的那个 pid 还在吗",不在就**自己**走正常停机。
8
+ * 再没有人会给它发 SIGTERM。`DB_BACKEND=local` 上还多一层:那只孤儿抱着 `LOCAL_DATA_ROOT/LOCK`
9
+ * (core `FileStorageBackend` 的 pidfile,失效判定是 `process.kill(pid,0)` ⇒ **活孤儿是合法持锁人**),
10
+ * 于是此后每一只新壳起自己的引擎都撞锁 fatal「another instance (pid N) owns this data dir」—— 用户看到
11
+ * 的是「壳崩过一次以后就再也起不来了」。持锁 pid 不存在时 core 已自动剪陈旧锁,缺的只有「父死、子活」
12
+ * 这一形。所以引擎给自己装一只**自查**腿:定期问一句"生我的那个还在吗",不在就**自己**走正常停机。
10
13
  *
11
14
  * ## 机制(逐条都是契约)
12
15
  *
13
16
  * 1. **opt-in**:`SEMA_PARENT_PID` 缺席 ⇒ 整件不装配(`boot/shutdown.ts` 连 `createParentWatch` 都不调)。
14
- * 存量部署逐字零变化。坏值拒启在 `config.ts` 的 `parentPidEnv()`。
15
- * 2. **探活 = `process.kill(pid, 0)`**:0 号信号不投递任何信号,只做"这个 pid 存在吗 + 我能不能给它
16
- * 发信号"的检查。两种失败方向**语义相反**,写反了后果不对称:
17
- * · `ESRCH` = 没有这个进程 ⇒ 父**不在**(这是我们要抓的);
18
- * · `EPERM` = 进程**在**,只是本进程无权给它发信号(父跑在另一个 uid 下 —— systemd/su/容器里
19
- * 很常见)⇒ **算活着**。把 EPERM 当"不在",每台这种机器上的引擎会在开机后两拍内自杀。
20
- * 3. **连续两拍才判死**:单拍抖动(探活恰好撞上一次瞬时错误)不误杀。任何一拍看到"活着"就清零。
17
+ * 存量部署逐字零变化 —— systemd / launchd / 容器托管的引擎本来就 reparent 到 1,自退是事故。
18
+ * 坏值(非正整数 / 超 int32)⇒ **启动期拒**,在 `config.ts` 的 `parentPidEnv()`。
19
+ * 2. **判死 = 两支合取口一句,任一成立即判死**,每 {@link PARENT_WATCH_INTERVAL_MS} 一拍:
20
+ *
21
+ * · **强身份支 `reparented`**:`process.ppid` 不再是装配那一刻的那个值。生我的进程没了,内核就把
22
+ * 我过继给 init(或容器里的 subreaper)—— 这是**内核对亲子关系的直答**,不依赖 pid 的身份。
23
+ * 判据刻意**不写成 `ppid === 1`**:① 收养者未必是 1(PID 1 是 subreaper 的容器里不是);
24
+ * ② 壳本身就是 pid 1 的容器形里(`SEMA_PARENT_PID=1`),`=== 1` 会在**开机第一拍**把一台健康
25
+ * 引擎判死。「不再是生我的那个」两种形都答得对,而且不需要任何特判。
26
+ * 🔴 **前提**(codex 复审 R1 [high],验真后修):这一支只有在「被监视的 pid 就是生我的那个」时
27
+ * 才等价于「它没了」。壳经一层 wrapper spawn 引擎却仍传**自己**的 pid 时,引擎的直接父是
28
+ * wrapper —— wrapper 先退会让这一支把一台**壳还活着**的健康引擎连同 in-flight run 一起关掉。
29
+ * 前提在**装配那一刻**就能一次判完(`ppidAtArm === pid`),所以它是**装配期的开关**而不是每拍
30
+ * 的特判:不成立 ⇒ 强身份支整条不装,只留弱身份支(它在那一形下语义仍然正确),并且 armed 那行
31
+ * 改打 **warn** —— 覆盖面被降级了不许静默。
32
+ * · **弱身份支 `esrch`**:`process.kill(parentPid, 0)` 抛 `ESRCH`。0 号信号不投递任何信号,只做
33
+ * "这个 pid 存在吗 + 我能不能给它发信号"的检查。两种失败方向**语义相反**,写反了后果不对称:
34
+ * - `ESRCH` = 没有这个进程 ⇒ 父**不在**(这是我们要抓的);
35
+ * - `EPERM` = 进程**在**,只是本进程无权给它发信号(父跑在另一个 uid 下 —— systemd/su/容器里
36
+ * 很常见)⇒ **算活着**。把 EPERM 当"不在",每台这种机器上的引擎会在开机后一拍内自杀。
37
+ *
38
+ * 强身份支**先判**:它成立时这一拍连探活都不发 —— 弱判据没有资格给一个已经被过继走的进程改口
39
+ * (僵尸父那一形里 `kill(pid,0)` 恰恰恒成功)。
40
+ * 3. **单拍即判,不去抖**。[ref] 定稿是「15s 一拍、连续两拍才判死」(上界 30s),S-292 整条退役:
41
+ * 部署面的判据是「壳挂了 ≤5s 内数据根重新可用」,而 `ESRCH` / `ppid` 都不是会抖的读数(两者都是
42
+ * 内核的直答,不是采样值)。去抖买不到确定性,只买到 15s 的延迟和一个 30s 长的 pid-复用窗口。
21
43
  * 4. **判死后走 `drain`**,即 `boot/shutdown.ts` 里 SIGTERM 用的那一条 `drainThenShutdown` —— in-flight
22
- * run 的结算 / park 语义与人工停机**逐字一致**。本模块自己**从不** `process.exit`。
44
+ * run 的结算 / park 语义与人工停机**逐字一致**,而且收尾链里的 `backend.close()` 才是把 `LOCK`
45
+ * 真正还回去的那一步。本模块自己**从不** `process.exit`。已有停机属主(`isStopped`)时站下不重入。
23
46
  * 5. **interval 必须 unref**:监视腿不得成为"进程本该退出却退不掉"的理由(与 drain tick 刻意不 unref
24
47
  * 的取向相反 —— 那一只 tick 就是停机驱动,这一只只是观察者)。
48
+ * 6. **日志分档**([ref] 响亮律):装配 = `info`;判死与探活自坏 = `warn`(非计划内的停机、以及"这条腿
49
+ * 此刻是失效的",都不许混在 info 流里)。档位是**闭集** {@link ParentWatchLogLevel},装配层用
50
+ * `Record<ParentWatchLogLevel, …>` 派发 ⇒ 新增一档是编译错,不会静默落进 info。
25
51
  *
26
52
  * ## 已知残余(成文,现版不收窄)
27
53
  *
28
- * · **pid 复用**:两拍窗口(≤30s)内父 pid 被系统复用,探活看到的是新进程,监视会判"父还活着"而不自退。
29
- * · **僵尸父进程**(codex 复审 H,本机 perl fork 实测证实):`kill(pid, 0)` 只证明 **pid 表项**在,不证明
30
- * 进程还在跑 —— 父退出但**没被它自己的父进程 wait 回收**时留下 zombie(`ps STAT=Z`),表项还在,于是
31
- * 每一拍都判"活着",监视永不触发。触发条件是壳的**上级**不回收子进程(容器里的朴素 PID 1 是典型),
32
- * 终端 / launchd / systemd 起的壳不在此列。
33
- *
34
- * 两条都源于同一个根:`kill(pid, 0)` 是**弱身份**判据。收窄需要一个真身份锚(`process.ppid` 变化、
35
- * starttime、pidfd、或壳侧持有型管道),那是**机制换代**而不是参数微调,归设计属主裁。本件在定稿里
36
- * 就是**尽力自愈**、不是强一致的父子生命周期绑定 —— 要强一致,该由壳侧开一条持有型管道并在断开时收尸。
54
+ * · **装配前就死掉的父**:监视腿装在 `listen()` 之后,`ppidAtArm` 也是那一刻取的。父在 boot 途中就死
55
+ * **且**成了僵尸(它自己的上级不回收子进程)时,强身份支没有"变化"可看、弱身份支被僵尸的 pid 表项
56
+ * 骗住,两支都不触发。父只是普通地死掉(被回收)时弱身份支照样抓得到 —— 只有"boot 窗内 + 僵尸"这个
57
+ * 交集漏。要收口需要壳侧一条**持有型管道**(壳退出=管道 EOF),那是机制换代,归设计属主裁。
58
+ * · **中间包装进程**:壳经一层 wrapper spawn 引擎(壳传自己的 pid,而引擎的直接父是 wrapper)时,强身份
59
+ * 支整条不装(见上,装配期判定 + warn),于是这一形退回 [ref] 的老覆盖面:僵尸壳抓不到。约定仍是
60
+ * **壳直接 spawn 引擎**(`node dist/main.js`)—— 那一形两支都在。
37
61
  */
38
- /** 探活拍频。**固定值,不开 env 旋钮**(设计定稿:有真需求再议,防旋钮增殖)。 */
39
- export declare const PARENT_WATCH_INTERVAL_MS = 15000;
62
+ /** 探活拍频。**固定值,不开 env 旋钮**(设计定稿:有真需求再议,防旋钮增殖)。
63
+ * 2s 的出处 = 部署面预算「壳挂掉后 ≤5s 数据根重新可用」减去一次 drain 的余量。 */
64
+ export declare const PARENT_WATCH_INTERVAL_MS = 2000;
65
+ /** 日志档位闭集。装配层按 `Record<ParentWatchLogLevel, …>` 派发 —— 新增一档必须在那里显式安置(编译错)。 */
66
+ export type ParentWatchLogLevel = "info" | "warn";
67
+ /** 判死缘由闭集(上 `parent_watch_exit` 的 `reason` 键,运维按它区分两支)。 */
68
+ export type ParentDeathReason = "reparented" | "esrch";
40
69
  export interface ParentWatchDeps {
41
70
  /** 被监视的父 pid(`config.parentPid`,已在 config 层校验为正整数)。 */
42
71
  pid: number;
43
72
  /** 探活 seam:抛错 = 探活失败(`.code` 携 errno)。缺省 = `process.kill(pid, 0)`。
44
73
  * 存在的理由不是"可配置",是 `ESRCH`/`EPERM` 两支在测试里没有确定性的自然触发法。 */
45
74
  probe?: (pid: number) => void;
75
+ /** 亲子关系 seam:缺省 = `process.ppid`(**活读**,不是 boot 时的快照 —— 实测过继后当场变)。
76
+ * 存在的理由与 `probe` 相同:reparent 在进程内测试里没有确定性的触发法。 */
77
+ readPpid?: () => number;
46
78
  /** 已有停机属主(`closing || draining`)时监视腿站下:一次正在进行的优雅 drain 不该被"父也没了"
47
79
  * 升级成第二信号硬停(那会斩掉 in-flight leg —— 与本件"和人工停机逐字一致"的取向相反)。 */
48
80
  isStopped: () => boolean;
49
81
  /** 判死出口 = SIGTERM 那条 graceful drain。 */
50
82
  drain: () => void;
51
- log: (event: string, fields: Record<string, unknown>) => void;
83
+ log: (level: ParentWatchLogLevel, event: string, fields: Record<string, unknown>) => void;
52
84
  }
53
85
  /** 装配并**立刻**起监视(装配即 `parent_watch_armed`)。返回停机链用的 `stop()`。 */
54
86
  export declare function createParentWatch(deps: ParentWatchDeps): {
@@ -1,13 +1,25 @@
1
- export const PARENT_WATCH_INTERVAL_MS = 15_000;
1
+ export const PARENT_WATCH_INTERVAL_MS = 2_000;
2
2
  export function createParentWatch(deps) {
3
3
  const probe = deps.probe ?? ((pid) => void process.kill(pid, 0));
4
- let firstMissAtMs;
4
+ const readPpid = deps.readPpid ?? (() => process.ppid);
5
+ const ppidAtArm = readPpid();
6
+ const directParent = ppidAtArm === deps.pid;
5
7
  let stopped = false;
6
8
  let probeErrorReported = false;
7
- deps.log("parent_watch_armed", {
9
+ deps.log(directParent ? "info" : "warn", "parent_watch_armed", {
8
10
  pid: deps.pid,
11
+ ppidAtArm,
12
+ directParent,
9
13
  intervalMs: PARENT_WATCH_INTERVAL_MS,
10
- note: "engine watches this pid; TWO consecutive ESRCH probes trigger the same graceful drain as SIGTERM (EPERM = alive). Unset SEMA_PARENT_PID to disable.",
14
+ note: directParent
15
+ ? "engine watches this pid; it enters the same graceful drain as SIGTERM as soon as EITHER the engine is " +
16
+ "reparented away from it OR process.kill(pid,0) reports ESRCH (EPERM = alive). " +
17
+ "Unset SEMA_PARENT_PID to disable."
18
+ : "SEMA_PARENT_PID is NOT this engine's direct parent (ppid differs at arm time) — a wrapper process sits " +
19
+ "in between. The reparent arm is therefore NOT armed (it would stop a healthy engine when the wrapper " +
20
+ "exits while the watched process is still alive); only the process.kill(pid,0)/ESRCH arm is active, so a " +
21
+ "watched parent that exits WITHOUT being reaped (zombie) will not be detected. Spawn the engine directly " +
22
+ "from the process whose pid you pass.",
11
23
  });
12
24
  const timer = setInterval(() => {
13
25
  if (stopped)
@@ -16,41 +28,44 @@ export function createParentWatch(deps) {
16
28
  stop();
17
29
  return;
18
30
  }
19
- let alive = true;
31
+ const ppid = readPpid();
32
+ if (directParent && ppid !== ppidAtArm) {
33
+ fire("reparented", { ppidAtArm, ppid });
34
+ return;
35
+ }
20
36
  try {
21
37
  probe(deps.pid);
22
38
  }
23
39
  catch (err) {
24
40
  const code = err?.code;
25
- alive = code !== "ESRCH";
26
- if (code !== "ESRCH" && code !== "EPERM" && !probeErrorReported) {
41
+ if (code === "ESRCH") {
42
+ fire("esrch", { ppid });
43
+ return;
44
+ }
45
+ if (code !== "EPERM" && !probeErrorReported) {
27
46
  probeErrorReported = true;
28
- deps.log("parent_watch_probe_error", {
47
+ deps.log("warn", "parent_watch_probe_error", {
29
48
  pid: deps.pid,
30
49
  code: code ?? "unknown",
31
50
  err: String(err),
32
- note: "liveness probe failed with an errno that is neither ESRCH nor EPERM — treated as ALIVE (conservative), so this watch will NOT self-terminate while this persists",
51
+ note: "liveness probe failed with an errno that is neither ESRCH nor EPERM — treated as ALIVE " +
52
+ "(conservative), so the pid arm of this watch will NOT self-terminate while this persists " +
53
+ "(the reparent arm is unaffected)",
33
54
  });
34
55
  }
35
56
  }
36
- if (alive) {
37
- firstMissAtMs = undefined;
38
- return;
39
- }
40
- if (firstMissAtMs === undefined) {
41
- firstMissAtMs = Date.now();
42
- return;
43
- }
57
+ }, PARENT_WATCH_INTERVAL_MS);
58
+ timer.unref?.();
59
+ function fire(reason, fields) {
44
60
  stop();
45
- deps.log("parent_watch_exit", {
61
+ deps.log("warn", "parent_watch_exit", {
46
62
  pid: deps.pid,
47
- firstMissAtMs,
48
- secondMissAtMs: Date.now(),
49
- note: "parent pid gone on two consecutive probes — entering the SIGTERM graceful drain path",
63
+ reason,
64
+ ...fields,
65
+ note: "the parent that spawned this engine is gone — entering the SIGTERM graceful drain path",
50
66
  });
51
67
  deps.drain();
52
- }, PARENT_WATCH_INTERVAL_MS);
53
- timer.unref?.();
68
+ }
54
69
  function stop() {
55
70
  if (stopped)
56
71
  return;
@@ -0,0 +1,94 @@
1
+ import type { Pool as MysqlPool } from "mysql2/promise";
2
+ import type { Pool as PgPool } from "pg";
3
+ import { type SqlDriver } from "./sql-driver.js";
4
+ /** 表名(单数形;`test/schema-naming-invariants.test.ts` 的闭集词表钉着)。 */
5
+ export declare const APPROVAL_NONCE_TABLE = "approval_nonce";
6
+ /**
7
+ * nonce 的**字符上限**。与 `reason` 的上限同姿势:**拒而不截**(截过的字节签名器复现不了 ⇒ MAC 恒不匹配,
8
+ * 把一个可诊断的 400 变成费解的 401)。190 = 本仓键列的标准宽度(`VARCHAR(190)`);以 JS 的 UTF-16 长度
9
+ * 设限是**保守**方向(星平面字符在 JS 里算 2、在两个引擎的 VARCHAR 里算 1),永远不会撞到列宽。
10
+ */
11
+ export declare const MAX_APPROVAL_NONCE_CHARS = 190;
12
+ export interface ApprovalNonceStore {
13
+ /**
14
+ * **这只店为它的记录计时的那口钟**(epoch ms)—— 见头注「到期 —— 一口钟」。
15
+ *
16
+ * 它是消费记录这条命上**唯一**的参照:受理窗的判定、落地后的复核、到期清扫三处全读它,于是
17
+ * 「还受理 ⇒ 记录还在」是结构性的,不靠「两台副本的钟差不多」这条没人声明过的假设。
18
+ *
19
+ * ⚠️ **要求:单调不减**。同源只挡「两只钟各走各的」,挡不住「这一只往回走」—— 钟回拨会让一条已被
20
+ * 清扫的记录所对应的证明重新过验签(头注「由构造」那段的残余,🚧 见 `approval-nonce-store.test.ts`)。
21
+ * 现役三实现都**不**自己强制这一条(Memory/File = `Date.now()`,SQL = 库钟),登记在案。
22
+ * 🔴 读不出来 ⇒ **响亮抛**([ref] fail-closed):调用方必须把它当不确定失败,**绝不**掉回本地墙钟。
23
+ */
24
+ nowMs(): Promise<number>;
25
+ /**
26
+ * 消费 `(sessionId, nonce)` —— **恰好一次**。
27
+ * @param expiresAtMs 这份证明还能被受理的最后一刻(epoch ms,**按 {@link nowMs} 这口钟**)。
28
+ * @returns `true` = 本次调用消费掉了它(首次);`false` = 它**已经**被消费过(= 重放)。
29
+ */
30
+ consume(sessionId: string, nonce: string, expiresAtMs: number): Promise<boolean>;
31
+ /**
32
+ * GC:删掉 `expires_at_ms <= ` **{@link nowMs}** 的记录,返回删除行数(维护 tick 调)。
33
+ *
34
+ * 🔴 **一个时钟入参都没有**,而且这不是省事:形参会让删除线由**跑清理的那台副本**说了算,而它跟
35
+ * 验签那台不是同一口钟 —— 那正是本店要消灭的那条依赖(与票据店 [ref] 删掉 `now: () => number`
36
+ * 同一条律:留着那只形参会让人以为「注一只假钟就能改记录的判决」)。测试要制造过期,把
37
+ * `consume` 的 `expiresAtMs` 相对 {@link nowMs} 往前挪即可 —— 那也是生产上唯一能让记录过期的方式。
38
+ */
39
+ reapExpired(): Promise<number>;
40
+ }
41
+ export declare class SqlApprovalNonceStore implements ApprovalNonceStore {
42
+ private readonly db;
43
+ constructor(db: SqlDriver);
44
+ private q;
45
+ /** 库钟({@link sqlDbNowMs})—— 一组无状态副本唯一共享的那口钟。读不出 ⇒ 响亮抛。 */
46
+ nowMs(): Promise<number>;
47
+ consume(sessionId: string, nonce: string, expiresAtMs: number): Promise<boolean>;
48
+ reapExpired(): Promise<number>;
49
+ }
50
+ /** MySQL 协议腿的构造绑定(与兄弟店同形:类名/构造元数不变,消费方不必动)。 */
51
+ export declare class TiDBApprovalNonceStore extends SqlApprovalNonceStore {
52
+ constructor(pool: MysqlPool);
53
+ }
54
+ /** PostgreSQL 腿的构造绑定。 */
55
+ export declare class PgApprovalNonceStore extends SqlApprovalNonceStore {
56
+ constructor(pool: PgPool);
57
+ }
58
+ export declare class MemoryApprovalNonceStore implements ApprovalNonceStore {
59
+ private readonly rows;
60
+ /** 单进程腿:店 = 这个进程,所以店钟就是本进程的墙钟(验签、复核、清扫本来就同一只钟,无第二口)。 */
61
+ nowMs(): Promise<number>;
62
+ consume(sessionId: string, nonce: string, expiresAtMs: number): Promise<boolean>;
63
+ reapExpired(): Promise<number>;
64
+ }
65
+ /**
66
+ * local 车道的持久腿:**单个**追加日志 + 内存索引 + 到期时的原子压实。
67
+ *
68
+ * 为什么不是「按会话一个文件」(兄弟 `FileApprovalExemptionStore` 的形):那只店的行**随会话长存**,
69
+ * 按会话分片才能按会话删;本店的行**至多活 ~150 秒**、且从不按会话查询 —— 分片只会在数据根里长出
70
+ * 一堆几行就作废的小文件。单文件 + 压实与行的生命周期同构。
71
+ *
72
+ * 崩溃语义:`append` 走 fsync。丢一条记录 = 那个 nonce 在它剩下的 TTL 里可被重放一次(与「整台副本重启」
73
+ * 的既有暴露同级,且 SQL 车道不受此影响);**绝不**能出现的是反向 —— 记录还在却被读成不在,那要靠
74
+ * `readJsonlRecords` 的断尾安全(半行丢弃)兜住。
75
+ */
76
+ export declare class FileApprovalNonceStore implements ApprovalNonceStore {
77
+ private readonly path;
78
+ private readonly tmpDir;
79
+ /** key → 盘上那条记录**本身**(不是它的到期时刻):压实要把幸存者原样写回去,而把 `(sessionId, nonce)`
80
+ * 从复合键里**解析**出来是一条没必要的反向铸形 —— 记录留着就不必解析。 */
81
+ private readonly rows;
82
+ private log;
83
+ constructor(root: string);
84
+ private logFor;
85
+ /** local 车道:店 = 这台机器上的这个数据根,所以店钟就是本进程的墙钟(理由同 Memory 腿)。 */
86
+ nowMs(): Promise<number>;
87
+ consume(sessionId: string, nonce: string, expiresAtMs: number): Promise<boolean>;
88
+ reapExpired(): Promise<number>;
89
+ /** 释放追加 fd(`LocalBackend.close` → 优雅重启重开)。
90
+ * 刻意**不包 try/catch**:core 的 `AppendLog.close()` 自己就是全函数(`releaseFd` 内部吞掉 `closeSync`
91
+ * 的错),再包一层空 catch 只是一段永不执行的静默降级码 —— 兄弟 File 店那一层是历史包袱,不复制。 */
92
+ dispose(): void;
93
+ }
94
+ //# sourceMappingURL=approval-nonce-store.d.ts.map