import { type UsageRow } from "../usage-analytics.js"; import type { TaskStatus } from "@sema-agent/core"; import { StoreBootRefusalError, type RunRecord, type SessionSummary, type RunEvent, type PersistedTaskResult, type LatestRunRef } from "./store-contracts.js"; import type { RunStoreCheckpointProbe } from "./memory-run-store.js"; import type { LedgerEventType } from "../trace/ledger-events.js"; /** * `RUN_STORE_STRICT_HYDRATE=on` 下 hydrate 列不出 `runs/` 时的**拒启**类型([ref] 件B;codex 对抗复审 * R1-[high] 验真后从裸 `Error` 升成具名类型)。 * * 🔴 **为什么必须是一个类型而不是一句文案**:`openStoreBackendWithFallback` 对**默认推导**的 local * (`DB_BACKEND` 未设)有一条既有降级臂 —— mkdir / 只读盘那类「这台机器存不了盘」的失败降级到内存继续起。 * 本拒启从 `new LocalBackend(...)` 里冒出来,长得和它们一模一样,不给它一个可判别的身份就会被同一个 * catch 吞掉:operator 明确要的 **fail-closed 反而变成 fail-open**,而且丢的正是持久 run 账本(这根旋钮 * 存在的全部理由)。判据锚在**类型**上,不在文案上 —— 文案会改,类型不会(同文件族先例:`AdoptionError` * 在同一个降级臂里也是按类型无条件重抛)。 * * 🔴 **S-384:本类自此 `extends StoreBootRefusalError`**(族规基类,见那条顶注)。上面这段论证对 * **每一只**会在 `LocalBackend` 构造器里拒启的 File 店逐字成立,而逐个 `if instanceof` 手工登记会漏 —— * S-384 的 ask 账本拒启就漏过一次。继承 = 自动落在正确的一侧。 */ export declare class RunStoreStrictHydrateError extends StoreBootRefusalError { readonly name = "RunStoreStrictHydrateError"; } export declare class FileRunStore { private readonly runsDir; private readonly activeDir; private readonly tmpDir; /** taskId → RunRow (the run registry index). */ private readonly runs; /** sessionId → taskId — the single-active-run claim (the task_active table; index of active/*). */ private readonly active; /** taskId → events (kept seq-sorted on read), the event-log index. */ private readonly events; /** taskId → the open append log (one O_APPEND fd per run, lazily opened, held for lifetime). */ private readonly eventLogs; /** [ref] the checkpoint-table stand-in (see memory-run-store.ts {@link RunStoreCheckpointProbe} — the full * rationale lives on the interface); absent ⇒ the suspended-run reapers stay honest NO-OPs. */ private checkpointProbe?; /** [ref] —— 本实例的 claim 身份(见 {@link ClaimOwner} 的选型注)。 */ private readonly claimOwner; /** [ref] —— sessionId → 该会话 claim 获取的临界区链尾(见 {@link withClaimLock})。 */ private readonly claimLocks; /** 件6(#43 案 [ref]):启动期告警座(可选)。既有调用方不传 ⇒ 只走 fail-open 遥测那一半,不炸。 */ private readonly logger; /** [ref] 件B(census 第 29 行终局):`RUN_STORE_STRICT_HYDRATE` 的**部署级**表态。 * `true` ⇒ hydrate 的 readdir 失败拒启;缺席/`false` ⇒ 件6 的记债腿,行为字节不变。 */ private readonly strictHydrate; constructor(root: string, opts?: { error?(msg: string, fields?: Record): void; strictHydrate?: boolean; }); private runDir; private runJsonPath; private eventsPath; private activePath; private toRow; private toDisk; private toRecord; /** Replay the on-disk registry/claims/event-logs into the in-memory index on boot. Each plane is torn-tail-/ * partial-write-safe (run.json via atomicWriteFile can never be half-written; the active file via writeThenLink is * all-or-nothing; events.jsonl via readJsonlRecords drops a crash tail). A corrupt run.json (failed parse) is * skipped — that run is lost, not a poison that fails the boot. */ /** * hydrate 期**逐文件**读盘的统一口([ref] 件B / codex 对抗复审 R4-[high])。 * * `readJsonlRecords` 对 ENOENT 答 `[]`、对其余读错(EACCES / EIO / EISDIR / ENOTDIR)**抛普通 Error** * (亲验 core `dist/stores/file/fs-atomic.js`)。那个普通 Error 从构造器冒出去之后,在**默认 * 推导**的 local 后端上会被 `openStoreBackendWithFallback` 的降级臂吞成 memory —— 也就是说 strict 开着 * 也照样带着残缺账本(或直接换成内存)起动,两处 readdir 门白守。 * * ⇒ strict 档:非 ENOENT 的读盘失败一律转成 {@link RunStoreStrictHydrateError}(带路径 + 原文),由 * `store-backend.ts` 的按类型重抛穿过降级臂。**strict 关时逐字不变**:原样把那只 Error 抛回去(既不 * 吞、也不改形),既有的「坏行跳过、不毒 boot」宽容契约与既有的抛法一起原封保留。 */ private replay; /** * strict 档下的**存在性判别**(codex R4-[high] 的前半):`existsSync` 对 ENOENT 与 EACCES 同答 false, * 而这两句话在本门下必须分开 —— 前者是常态(`runs/` 下的非目录杂物、还没落盘的目录),后者是「我读 * 不出它有没有」。只在 strict 档多付一次 `statSync`;ENOENT/ENOTDIR 一族原样放行(继续跳过),其余 * 一律拒启。 */ private refuseIfUnreadable; private hydrate; /** Commit a RunRow to disk (whole-file atomic replace) AND the index — the single write path for every status change. */ private persist; private eventLogFor; /** Create a run iff the session has no active run (the task_active unique-key claim). The claim file is created with * writeThenLink (atomic): if it already exists (EEXIST) the session has an active run → lose, return the holder. */ createRun(taskId: string, sessionId: string, owner: string | null, instanceId: string, meta?: { jobId?: string | null; source?: string | null; objectivePreview?: string | null; }): Promise<{ ok: true; } | { ok: false; activeTaskId: string; }>; /** [ref] —— createRun 临界区的后半段:落 claim 索引 + run 行(两者一体,行写失败即回滚 claim)。 */ private commitNewRun; requestCancel(taskId: string, owner: string | null): Promise; isCancelRequested(taskId: string, owner: string | null): Promise; requestPreempt(taskId: string, owner: string | null): Promise; isPreemptRequested(taskId: string, owner: string | null): Promise; heartbeat(taskId: string, owner: string | null): Promise; appendEvent(taskId: string, seq: number, type: LedgerEventType, data: unknown): Promise; maxSeq(taskId: string): Promise; retainedFrom(taskId: string): Promise; getEvents(taskId: string, afterSeq: number): Promise; /** [ref] FIRST terminal writer wins(SQL 孪生同款正向 CAS on running/suspended/needs_review, * [1.207 codex M1] 负向形漏 blocked/timeout);claim 释放保持无条件(幂等)。 */ setTerminal(taskId: string, status: TaskStatus, result: PersistedTaskResult | null, error: string | null): Promise; /** Durable F4: park NON-terminal `suspended`, KEEP the task_active claim. CAS on running/suspended (reaper-revert guard). */ setSuspended(taskId: string): Promise; /** [ref] D-B: park NON-terminal `needs_review`, KEEP the claim. CAS on running/needs_review. */ setNeedsReview(taskId: string): Promise; /** Durable F4 resume: flip suspended/needs_review → running (CAS) + reset cancel/preempt flags. * [ref](codex R1 [medium]):认领 = 接管 `instanceId`(= 当前持有副本;SQL 孪生同注)。CAS 输不改。 */ markResuming(taskId: string, instanceId: string): Promise; getActiveTaskId(sessionId: string): Promise; getRun(taskId: string): Promise; /** Runs newest-first, keyset on (createdAt, taskId) DESC; optional exact-match filters (owner is exact, like the SQL). */ listRuns(opts: { status?: string; jobId?: string; source?: string; owner?: string; cursor?: { createdAt: string; taskId: string; }; limit: number; }): Promise; /** * S-297 —— 这个会话**最近一条腿**的窄指针({@link SqlRunStore.latestRunForSession} 的孪生:同一个排序口径 * `createdAt DESC, taskId DESC`,与本店 {@link FileRunStore.listSessions} 挑 `rn=1` 那一行的比较器逐字同一份)。 */ latestRunForSession(sessionId: string): Promise; /** DISTINCT sessions newest-first by last activity (the CC /resume picker): latest run's preview/status + first/last/count. */ listSessions(opts: { owner?: string; includeUnowned?: boolean; cursor?: { lastActivityAt: string; sessionId: string; }; limit: number; q?: string; }): Promise; /** E21 (§0.5 delete) — purge the runs ledger for one session, scoped by `session_id` ALONE (per-row owner is the * submitting principal and diverges from the session owner — full 判据 see the SQL twin's head-note, F-A); * aborts (returns {active}) if a run is live。`expectedSessionOwner` = SQL 双生的化身围栏入参,本孪生执行不了 * (账本里没有 session_meta),理由与替代围栏逐字见 {@link MemoryRunStore.deleteBySession}。 */ deleteBySession(sessionId: string, expectedSessionOwner: string | null): Promise<{ removed: number; } | { active: string; }>; /** 用量扫窗——in-memory 腿:遍历窗内行,投影同 usage-analytics 纯函数(SQL 孪生 parity)。 */ usageScan(fromMs: number, toMs: number, opts?: { owner?: string; limit?: number; }): Promise<{ rows: UsageRow[]; truncated: boolean; }>; sourceSummary(sinceMs: number): Promise>; /** Fail stale `running` rows (crashed-run backstop) + release task_active for any now-terminal row. */ /** Release any claim whose row is now terminal; KEEP suspended/needs_review (resume re-finds them). One place for * the claim-release invariant, shared by reapRunning + reclaimOrphanedAtBoot. */ private sweepClaims; /** Flip every `running` row matching `pred` → failed (with `reason`), then sweep terminal claims. */ private reapRunning; reapStale(olderThanMs: number): Promise; /** TOC integration ask③ (INTEGRATION-DESIGN §11): a host-lane engine restart leaves the PREVIOUS process's * `running` rows orphaned — their in-process driver (runInBackground, in the dead process's memory) is gone, but the * `active/` claim persists on disk and EEXIST-blocks a re-submit (the shell reads the session as stuck). * The boot flock (store-backend `LOCK`) already proves no concurrent live owner reached here: a still-alive old * engine holds it so the new one FAILS FAST; a dead one had it kernel-released. So a `running` row here is orphaned * → flip failed + release its claim UNCONDITIONALLY (no TTL wait, unlike reapStale's steady-state poll). * 🔴 EXCEPT a resume-in-progress row: `markResuming` flips a suspended run (suspended→running) while its pending * checkpoint still exists, and the resume picker will re-drive it — reclaiming it would STRAND a resumable session * as failed. `isResumable(sessionId)` (wired by main.ts boot, which holds the checkpointStore) excludes those; with * no callback every running row is treated as orphaned (conservative default when no checkpoint plane is wired). * HOST/FILE LANE ONLY — the cross-replica tidb/pg store uses heartbeat→reapStale (a running row there may be alive * on another replica; an unconditional reclaim would kill a live fleet run). */ reclaimOrphanedAtBoot(isResumable?: (sessionId: string) => Promise): Promise; /** [ref] wire the checkpoint probe (LocalBackend.checkpoint(), where checkpoint + run store share one root). */ setCheckpointProbe(probe: RunStoreCheckpointProbe): void; /** [ref] shared tail of both suspended-run reapers: flip one parked row → failed('approval.expired') and * PERSIST it — the file twin of the SQL UPDATE (`error`/`error_code` set directly, no result blob). */ private failExpiredPark; /** * [ref] Durable F4 expiry — the file twin of TiDBRunStore.reapSuspended, driven by the injected checkpoint * probe instead of a SQL JOIN (formerly a NO-OP — one of the five closed doors in the "suspended run locks the * session forever" incident). Semantics verbatim from the SQL twin: suspended ∧ updatedAt < cutoff ∧ NOT * probe.hasPending(session) → failed 'approval expired before decision' / errorCode 'approval.expired' + * release the task_active claim. The NOT-pending clause is the [ref] D-D blocker guard * (SQL 孪生现在的真身:`run-store-sql.ts` 的 `reapSuspended`;`tidb-run-store.ts` 该壳文件已删(5.0.0)): a row whose checkpoint is STILL pending belongs to the checkpoint-state sweeps — * time-reaping it would orphan a model leg on a now-unlocked session. Probe absent ⇒ honest NO-OP. * MemoryRunStore is the parity oracle (byte-identical predicate; only the persist differs). */ reapSuspended(olderThanMs: number): Promise; /** * [ref] [ref] §3 inv#3 crash-safe backstop — the file twin of TiDBRunStore.failSuspendedWithExpiredCheckpoint * (checkpoint-STATE-driven, not time-driven; scheduled UNCONDITIONALLY by the main.ts reaper, no * APPROVAL_TIMEOUT_SEC gate). Semantics verbatim from the SQL twin: (suspended ∨ needs_review) ∧ * probe.hasExpired(session) ∧ NOT probe.hasPending(session) → same fail + claim release (wq64gmm5e: an * abandoned needs_review park leaks exactly like a suspended one; a re-suspended session that minted a NEW * pending gate is excluded by the NOT-pending clause). */ failSuspendedWithExpiredCheckpoint(): Promise; /** * [ref] —— 裁定/接管路径上唯一许可的取行方式:**盘优先**,盘上没有才回退内存索引。 * * 复审 R2 [high]:反过来(索引优先)会在「同进程两只实例、两边索引都热」时出事 —— A 把 park 行 * `markResuming` 成 running 并消费掉 checkpoint,B 的索引里还留着**旧的 park 对象**;B 于是读不到 * 变化、拿自己那份陈旧对象通过复核,把 A 正在跑的行覆盖为 failed 再放锁。盘是唯一权威,判据只能读盘。 * 回退内存索引只为「行还没落盘」这一种形(理论上不该出现,留着不让判据凭空缺行)。 * 代价:只在 EEXIST 争用路径上多一次读文件,常路零开销。 */ private authoritativeRow; /** [ref] —— 越过内存索引直接读盘上的那一行(索引可能比另一实例的写晚一步;判据必须以盘为准)。 * 读不出/坏行 ⇒ undefined,与 hydrate 的跳过口径一致。 */ private readRowFromDisk; /** * A1/codex R2 —— 盘上扫该会话最新的非终局行:撕裂 claim(解析不出记录)唯一还能认主的通道。 * 内存索引不可用作判据 —— 同进程冷索引姊妹实例的行可能晚于本实例 hydrate 才落盘(L9/[ref] 同族), * 判据必须以盘为准。O(盘上 run 数),只在「claim 解析不出记录」这条罕见路径上走,常路零开销。 * 行的读法与 hydrate/readRowFromDisk 同一套宽容口径(坏行跳过,不毒判定)。 */ private newestNonTerminalRowOnDisk; /** 本进程要落盘的 claim 形(带持有者身份 —— [ref] 的活性证据)。 */ private claimRecord; /** * [ref] —— **每会话**的 claim 获取临界区。 * * 为什么必须有:接管的判定链里有 `await`(checkpoint probe 的两条 EXISTS 谓词),两条并发 `createRun` * 会在同一枚陈旧 claim 上**各判各的**、然后**各接管各的** —— 第二条的 `rename` 搬走的是第一条刚写下的 * **新** claim,于是两条都拿到 ok:true,单活不变式被并发撕开。(这不是纸面推演:L7 那一格先红,就是 * 这条路径。`rename` 只保证「同一个源路径只成功一次」,它**不是**对被判定那份内容的 CAS。) * * 为什么进程内互斥就够:这条车道是「一个数据根一个写者」——`FileStorageBackend` 的 `root/LOCK` 让第二个 * 活进程 fail fast。跨进程并发在本车道**不存在**;而这正是接管判据本身所依赖的同一条不变式 * (`reclaimOrphanedAtBoot` 也全靠它),所以这里没有引入新的假设。`rename` 仍然留着当廉价二道闸: * 判定与接管之间 claim 若已被别腿正常释放,它给 ENOENT,整轮重来。 * * 形状:每 session 一条 promise 链,后来者 await 前一位;链尾归零时删表项(不无界增长)。 */ private withClaimLock; /** [ref] —— 临界区内的 claim 获取本体:铸不下就裁定持有者,判陈旧则接管后重铸。有界重试(论证见 takeOverClaim)。 */ private acquireClaim; /** * [ref] —— 撞上 EEXIST 之后,对**现持有者**的裁定。默认方向是 `live`(挡住):只有能**证明** * 持有者已死的形才判 `stale`。证不出来一律 fail-closed —— 单活不变式比自愈更重要。 * * 三族: * ① 行不在 / 行已终局 —— claim 是残骸(R9 回滚失手、跨进程窗)。`sweepClaims` 本该收掉, * 它只在 boot 与几条 reaper 上跑;这里就地补齐。 * ② 行 `running` —— 有没有在飞的驱动腿,取决于**谁**写的这枚 claim:本进程写的 ⇒ 驱动腿就在 * 本进程内存里 ⇒ 真活主;别的进程写的 ⇒ 那个进程已死(boot 单写者锁作证)⇒ 驱动腿随它一起没了, * 行永远不会自己走到终局。这与 `reclaimOrphanedAtBoot` 的判决**逐字同源**,只是时点不同。 * ③ 行 `suspended`/`needs_review`(park)—— park 按契约**没有在飞的腿**(腿返回 suspended 后就退出了, * resume 腿会先 `markResuming` 翻回 running),所以这一族与「谁写的」无关,只问一件事: * **还有东西可续吗**。有 pending ⇒ 可续(409 体里的 pendingGate 就是出路);有 expired ⇒ 归 * [ref] `failSuspendedWithExpiredCheckpoint` 那条腿收(它要落 `approval.expired` 这个终局语义, * 本函数不许抢);两者皆无 ⇒ 这条 park 谁也续不动,而**没有任何一条排期腿够得着它** * (reapStale 只碰 running;boot 回收刻意跳过 park;时间腿 reapSuspended 挂 APPROVAL_TIMEOUT_SEC, * 默认 0 = 不排期)⇒ 永久占用,判 stale。probe 缺席 ⇒ 证不出「无可续」⇒ fail-closed 判 live。 */ private judgeClaim; /** * [ref] —— 接管一枚已判定陈旧的 claim。返回 false = 现场已经变了 / 抢输了,调用方整轮重来。 * **整段同步**(无 await)⇒ 进程内不可被打断;跨进程由 rename 与单写者锁兜。 * * ① **复核先行**(复审 codex R1 [critical]):judgeClaim 的 probe await 期间,resume 腿可能已经 * `markResuming`、终局腿可能已经放锁重铸。所以第一件事是拿判定时的快照逐字比对**盘上现状** * (claim 的 taskId+owner nonce、行的 status+updatedAt)。任何一项动了 ⇒ 判决作废,不许动手。 * ② **先把行写死,再拆 claim**(复审 codex R1 [medium]):次序反过来的话,「claim 已拆、行还 park」 * 这半拍崩溃会留下一条**永远 park** 的行 —— boot 回收刻意跳过 park、sweepClaims 又没有 claim 可循, * 没人收得掉。现在的次序里,同一处崩溃留下的是「行已终局 + claim 还在」,而那正是 `sweepClaims` * 每次 boot / 每条 reaper 都会扫掉的形 —— 崩在哪一步都自愈。 * ③ 拆 claim 用 `renameSync(claim → tmp/隔离名)`:POSIX `rename` 对同一个源路径只可能**成功一次**, * 并发的第二个 racer 拿 ENOENT。隔离件落在 `tmp/` 而不是 `runs/active/` —— `hydrate()` 把 activeDir 里的 * **每个文件**都当 claim 读,隔离件留在那儿就等于把刚拆掉的幽灵在下次 boot 原样复活。 */ private takeOverClaim; /** Release a session's single-active claim: unlink the claim file + drop the index entry (idempotent). */ private releaseClaim; /** Drop a run entirely (registry + event log + open fd + on-disk dir) — used by deleteBySession. */ private dropRun; /** * 🔴 **一次性迁移(随 7.75.0 连同 `one-time-migrations.ts` 整删)** —— 退役词旧拼法的账本改写, * SQL 两孪生那条整表 UPDATE 的 local 形。逐条走**同一只**纯改写函数,所以三条车道改出来的字节一致。 * * 为什么是 store 上的一只动词、而不是外面一个文件走查:本店的账本有**两份**在场 —— 盘上的 jsonl * 与 hydrate 进来的内存索引。外部走查只改得到前者,读腿读的却是后者(`getEvents` 从 Map 取), * 于是迁移在本次进程里完全不生效、下次重启才"忽然"生效。两份一起改的唯一正确位置就是这里。 * * 写盘用整文件原子替换(不是追加):改的是**已有行**,而 `AppendLog` 只会往后写。替换之前把**全部** * append fd 撤掉(rename 之后旧 fd 指向的是被 unlink 的 inode,继续往那儿追加 = 之后的事件全部写进一个 * 没人看得见的文件),下一次 `appendEvent` 会按惰性规则重新开。撤 fd 复用**既有的那一只** {@link dispose} * —— 刻意不在这里另写一段 close+catch:那会是本文件第二处同形的兜底臂,而规则要变少不要变多。 * 常态下这一撤是空操作:本动词跑在 boot 期,那时一个 append fd 都还没惰性开出来。 * * 幂等:零命中 ⇒ 一个字节都不写、一只 fd 都不撤(第二跑零 I/O)。 * @returns 被改写的账本**行**数(不是 run 数)。 */ migrateRetiredAskOriginRows(): Promise; /** Release every open event-log fd (called by LocalBackend.close so a graceful restart can re-open the data dir). */ dispose(): void; } //# sourceMappingURL=file-run-store.d.ts.map