import type { TaskStatus } from "@sema-agent/core"; import type { Pool as MySqlPool } from "mysql2/promise"; import type { Pool as PgPool } from "pg"; import { type UsageRow } from "../usage-analytics.js"; import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult, LatestRunRef } from "./store-contracts.js"; import { type SqlDriver } from "./sql-driver.js"; import type { LedgerEventType } from "../trace/ledger-events.js"; export type { RunRecord, SessionSummary, RunEvent, LatestRunRef } from "./store-contracts.js"; export declare class SqlRunStore { protected readonly db: SqlDriver; constructor(db: SqlDriver); /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */ private q; /** Dup-key classification — single owner in `sql-errors.ts` ([ref] P1-①; the old local form read * only mysql2's string `code`, so an error carrying just `errno` fell through the claim-race arm). */ private isDupKey; /** JSON column binding: TiDB stores the string verbatim; PG goes through the protocol-safe envelope * (a raw NUL byte in an event payload would otherwise throw 22P05 mid-write). */ private json; /** Run statements atomically; rolls back on failure. */ private tx; /** * Create a run, but only if the session has no other active run (double-text guard). * * Mutual exclusion is a unique-key claim on `task_active(session_id)` — NOT a SELECT…FOR UPDATE, * which on TiDB (no gap locks) would fail to serialize two concurrent createRuns when the session * has no prior row. A plain INSERT either claims the session or throws a duplicate-key error (TiDB * does NOT report a reliable affectedRows for `ON DUPLICATE KEY UPDATE …=…`, so we catch dup-key). */ 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; }>; /** Request cancel of a RUNNING run (durable, cross-replica). Sets the flag the owning instance's heartbeat * tick polls; a same-replica caller additionally aborts the in-flight controller for instant effect. Only * flags a `running` row (terminal/suspended → 0 affected = no-op). Returns whether a running row was flagged. * * `owner` is a single-DB-fleet defense-in-depth guard: the WHERE pins the row to the authenticated owner * (null-safe compare so a legacy null-owner run matches owner=null), so even if the HTTP owner-gate has a bug * a caller can never flip another tenant's run. Pass the run's owner (from the looked-up RunRecord). */ requestCancel(taskId: string, owner: string | null): Promise; /** Whether a cancel has been requested for this run (polled by the owning instance's heartbeat tick). * `owner` is the single-DB-fleet null-safe owner guard (see {@link requestCancel}). */ isCancelRequested(taskId: string, owner: string | null): Promise; /** [ref] seam #2: request PREEMPT (graceful durable yield, ≠ cancel's kill) of a RUNNING run — durable + * cross-replica. Sets the flag the owning instance's heartbeat tick polls (it then aborts its preemptSignal → * the task suspends at the next clean turn boundary, gate resource_limit/reason preempt); a same-replica caller * additionally aborts the in-flight preempt controller for instant effect. Only flags a `running` row * (terminal/suspended → 0 affected = no-op). Returns whether a running row was flagged. * `owner` is the single-DB-fleet null-safe owner guard (see {@link requestCancel}). */ requestPreempt(taskId: string, owner: string | null): Promise; /** Whether a preempt has been requested for this run (polled by the owning instance's heartbeat tick). * `owner` is the single-DB-fleet null-safe owner guard (see {@link requestCancel}). */ isPreemptRequested(taskId: string, owner: string | null): Promise; /** Liveness heartbeat: refresh updated_at for a still-running run, independent of events. The * running instance calls this on a timer so a long-but-alive turn (e.g. a subagent that emits no * parent events for minutes) is not mistaken for a dead instance. * `owner` is the single-DB-fleet null-safe owner guard (see {@link requestCancel}); the heartbeat is * always issued by the owning instance with the run's own owner, so this only narrows, never blocks. */ heartbeat(taskId: string, owner: string | null): Promise; /** Append one event. A single INSERT — `updated_at` (liveness) is owned by `heartbeat`, so there * is no second statement to keep atomic here. */ appendEvent(taskId: string, seq: number, type: LedgerEventType, data: unknown): Promise; /** Highest event seq for a task (0 if none). Lets a resumed leg CONTINUE the durable event log past the * suspend (core 1.70 `resumeStream`) — the suspend wrote events 1..K, the resume appends from K+1 without * colliding on the (task_id, seq) PK. */ maxSeq(taskId: string): Promise; /** Mark terminal AND release the single-active-run claim atomically — if the DELETE were a separate * statement that failed, the session would stay permanently locked. [ref] FIRST terminal writer wins: * the UPDATE is CAS'd POSITIVELY on the live statuses (running/suspended/needs_review) — every call site * is a settle-once flow whose backstops may double-fire (cancel × leg-throw × reaper), and an * unconditional write let the LAST racer overwrite errorCode (a `cancelled` row flipping to a generic * abort text, or vice versa). Positive form ([1.207 codex M1]): the negative NOT IN missed the * blocked/timeout terminals — a first write of those could still be overwritten. The claim DELETE stays * unconditional (idempotent; the first writer already released it). */ setTerminal(taskId: string, status: TaskStatus, result: PersistedTaskResult | null, error: string | null): Promise; /** * Durable F4 ([ref]): a task suspended on a policy `ask`. Sets the run NON-terminal `suspended` and * KEEPS the `task_active` claim — the session stays locked (no new run) until an operator resumes it to a * terminal state or the reaper expires it. The token-bearing result is NOT stored (the submitter polling * this row must never receive the capability token); only the status flips. */ setSuspended(taskId: string): Promise; /** * [ref] D-B: PARK a run that paused on a REVIEW gate — a `needs_review` result (a dry-run diff review OR a * pre-action `plan_review`; core assemble-result slot 8.6). Like {@link setSuspended} this is a NON-terminal park * that KEEPS the `task_active` session claim (it does NOT delete it — only {@link setTerminal} does), so the * resume path (`getActiveTaskId` → `markResuming`) can find + claim the row. core treats a review pause "like a * suspend, an INTENTIONAL durable pause" ([ref] §2.5) — the service MUST park it the same way, else the run is * terminalized + its lock released and the plan_review/dry_run_review resume can never run (it gets * taskId=undefined and drives the model unprotected on an unlocked session). CAS on `running`/`needs_review` * (reaper-revert guard, mirrors setSuspended). The park→resume→claim invariant is pinned on BOTH REAL engines by * `test/run-store-reaper-integration.test.ts` (`inv#1` row ownership across park→resume→handover, `inv#2` two * replicas racing one session ⇒ exactly one claim wins, `inv#3`/`inv#3'` stale-claim takeover vs fresh/parked * claims), closing the former required-before-GA gap. Keep it that way: the P2 lease-SQL bug and this * needs_review-park bug were both born from store SQL that mocked unit tests pass — measured, not asserted, when * the invariant set was built: an upserting (non-exclusive) claim reddened all four real-DB cases while all 111 * mocked run-store unit tests stayed green. A mock cannot observe a unique-key claim race, a rolled-back loser, * or a multi-table reaper DELETE. */ setNeedsReview(taskId: string): Promise; /** * Durable F4 resume: a suspended run is being actively resumed. Flip it back to `running` (CAS — only if * still suspended) and refresh liveness, so the in-flight resume is protected by the running-run heartbeat * and is NOT killed by `reapSuspended` (which targets `status='suspended'` past the approval TTL). Without * this the row sits at `suspended`/updated_at=suspend-time for the WHOLE resume execution, so a resume that * crosses the TTL is marked `failed` mid-run and its session lock (`task_active`) is released. Returns true * if it claimed the flip (false ⇒ the reaper already expired it, or it isn't suspended). * * [ref](codex R1 [medium],红先双库证):认领 = **接管 `instance_id`**。此前该列只在 createRun 铸、此处不动, * 于是 A 创建、C 认领 resume 的行永远记 A —— 而 steer/interrupt 的「他副本」臂与 sendHandleMiss 都拿这列当 * 「当前持有者」证据:verify 形 resume 腿(不登记 steerable)在 C 上跑时,C 读到 A≠C 就谎报「run 在别的副本」。 * 语义自此成文:`instance_id` = **当前持有副本**(createRun 铸、markResuming 认领接管);CAS 输不改列 * (一个输掉的认领不许覆盖赢家的持有记录——同一条条件 UPDATE 天然保证)。 */ markResuming(taskId: string, instanceId: string): Promise; /** The active run's taskId for a session (the `task_active` claim) — lets resume find the suspended run row. */ getActiveTaskId(sessionId: string): Promise; /** Shared row→record mapper — byte-identical between dialects (both drivers hand back the same column * names + comparable JS types after SqlDriver's result normalization). * 🔴 **读它的每一条查询都必须选 {@link RUN_ROW_COLS}**(S-230):映射器对「没被 SELECT 的列」是 * 结构性失明的 —— `undefined ?? null` 与 `Boolean(undefined)` 都会安静地铸出一个**看上去合法的假值**。 * 修前 `listRuns` 的两条方言臂就漏选 `instance_id`,于是列表面的每一行 `instanceId` 恒 `null` * (今天没有消费点,所以没人发现)。列表收成一个常量 = 这类漏选从此不可能发生。 */ private rowToRun; getRun(taskId: string): Promise; /** Task Trace list (S1): runs newest-first, keyset-paginated on (created_at, task_id). Optional status + * jobId filters (jobId = the work-view group, served by idx_job). Caller passes `limit+1` to detect a next * page. Cursor fields are bound as params (no SQL injection). * * 🔴 Genuinely-divergent BUILDER (not just SQL text): TiDB's `?` is position-agnostic, so the WHERE array is * built with plain string pushes in call order. PG's `$n` needs a NUMBERED placeholder per param, so that arm * keeps its own positional-counter closure (`p()`) — collapsing the two into one shared counter would hide * the very placeholder-numbering divergence this file exists to keep visible. */ listRuns(opts: { status?: string; jobId?: string; source?: string; owner?: string; cursor?: { createdAt: string; taskId: string; }; limit: number; }): Promise; /** * S-297 —— 这个会话**最近一条腿**的窄指针(`/mcp` 面板的 `lastLegMcp` 与任何「上一条 run 是谁」的读面共用)。 * * 排序口径与 {@link listSessions} 的 `rn=1` 窗函数**逐字同一句**(`created_at DESC, task_id DESC`)—— * 两处答的是同一个问题「哪一条是最新的」,口径分叉会让列表面的 `lastRunId` 与本读口指向不同的 run。 * 走的也是同一条索引路径(`idx_task_run_session_status` 的 `session_id` 前缀),所以**不新加索引**: * 会话内行数有界,前缀定位之后的排序只在这一小撮行上。 * * 两方言只差占位符(动态子句为零,不像 listRuns/listSessions 那样需要各自的构造器臂)。 */ latestRunForSession(sessionId: string): Promise; /** GET /v1/sessions (CC /resume picker): DISTINCT sessions newest-first by last activity, keyset-paginated on * (last_activity, session_id). Aggregates task_run by session_id — one window pass picks the latest run per * session (rn=1) for the preview/status and rolls up first/last/count. Owner-scoped when set (the per-user view). * Bounded by per-tenant data today; a denormalized session table is the optimization if it ever goes hot. * * 🔴 Same builder divergence as {@link listRuns} — see its header note. */ listSessions(opts: { owner?: string; includeUnowned?: boolean; cursor?: { lastActivityAt: string; sessionId: string; }; limit: number; q?: string; }): Promise; /** Shared row→summary mapper for listSessions (byte-identical between dialects). */ private rowToSession; /** * E21 (§0.5 session delete) — purge the service-owned RUNS LEDGER for one session: task_event (the replayable * event log), task_active (the single-active-run claim), and task_run (the run rows). Idempotent — returns * `{ removed }` (the count of task_run rows removed; 0 = nothing to purge). The conversation HISTORY * (session_meta/session_event) is purged separately by the session store; the checkpoint/tool-result rows by * their stores — this method owns only the runs-ledger tables. Done in one TX so a partial purge can't leak a * task_active claim with no run row (a permanent session lock). * * 🔴 Scoped by `session_id` ALONE — deliberately NO per-row owner guard (F-A, 2026-08-17 合并码扫描;同一条 * 判断 `boot/session-faces.ts` 的 anchors 腿早就写过,run 账本这条腿当时没跟上):`task_run.owner` 是**每次 * 提交的 principal**,它与会话的 canonical owner 会分叉 —— 一条 `session_meta.owner = NULL` 的存量会话被 * 带 principal 的壳续聊后(REQUIRE_PRINCIPAL=false),新 run 行的 owner 是 `"default"`,而 DELETE 路由递进来 * 的是**会话行**的 owner(NULL)。行级 null-safe 属主门(`<=>` / `IS NOT DISTINCT FROM`)于是只删得掉 NULL * 那批,具名那批连同它们的 task_event 全部存活 —— 而 `session_meta` 已经删了,留存腿的 sessionCandidates * 再也枚举不到这棵树 ⇒ 孤儿行**没有任何清理路径**,路由却回 `{deleted:true}`。那就是一次**报成成功的 * 右删失效**(anchors 腿的注逐字写着这句)。删除决策的属主门在**路由层**(DELETE /v1/sessions/:id 先 * `ownerOf` 再比对 scope,fleet-wide 臂同门),本方法的唯一生产调用点是那条路由后面的 purge 协调器。 * * 🔴 **化身围栏**(`expectedSessionOwner`;codex 交叉复审 R1-[high],验真后修)—— 属主门下来了,但会话 * **身份**必须在同一个事务里重新确认。会话 id 由调用方自选、且删除之后**可被别人重新登记** * (`register` 是 INSERT … ON DUPLICATE KEY / ON CONFLICT,老行没了就是一条新行,新 owner):于是 * 「路由 `ownerOf` 已验权」这句话在两只并发 purge 交错时会过期 —— A 与 A' 同时通过属主门,A' 跑完整条 * purge 把 meta 删掉,租户 B 用同一个 uuid 重新登记并跑完一次 run,慢半拍的 A 才走到这里 ⇒ 按 session_id * 单键删就把 **B 的**行删了(修前的行级属主门恰好挡住这一形,因为 B 的行 owner≠A 的会话主)。 * ⇒ 事务头部 `SELECT owner FROM session_meta WHERE session_id = ? FOR UPDATE`,行不在或 owner 与路由验过的 * 那一位不符 ⇒ **一行不删**回 `{removed:0}`(诚实:属于调用方的那个化身已经不在了;协调器据此回 * `{deleted:false}`,不会拿别人的行数冒充成功)。锁序与留存腿逐字一致(先 session_meta 后 task_*), * 不引入新的死锁环;锁在本事务提交时释放,其后的 checkpoint / history 两腿各自带属主门。 * ⚠️ 本围栏只有 SQL 双生做得到 —— File/Memory 孪生的账本里没有 session_meta(local 车道的会话表在另一只 * 店里),它们的同名参数因此只作**契约对齐**用,真围栏由协调器那条 pre-delete 复读 + local 数据根的 * 独占 BootLock(一个数据根恒一个进程)承担;逐字见 memory-run-store 的同名方法注。 * * 🔴 Active-run re-assert INSIDE the TX (adversarial-review LOW — TOCTOU): the route's pre-purge `getActiveTaskId` * 409 check is a non-transactional SELECT — a concurrent createRun can claim `task_active(session_id)` in the * window between that check and this purge, then have its LIVE rows deleted here. So we re-take the no-active-run * invariant transactionally: `SELECT task_id FROM task_active WHERE session_id=? FOR UPDATE` at the TOP of the TX. * `FOR UPDATE` serializes against createRun's unique-key INSERT on the SAME session_id (createRun either already * holds the row — we see it and abort — or blocks on our lock until commit, then fails its own unique-key claim * against the now-absent row). If a claim exists we return `{ active }` (the route maps it to 409) and delete * NOTHING. Returns `{ removed }` on a clean purge. * * 🔴 The multi-table DELETEs are dialect-divergent SQL FORMS (not just placeholders): TiDB's * `DELETE te FROM te JOIN tr ON …` vs PG's `DELETE FROM te USING tr WHERE …`. */ deleteBySession(sessionId: string, expectedSessionOwner: string | null): Promise<{ removed: number; } | { active: string; }>; /** Per-door usage rollup (center systems-UI input): COUNT by (source,status) since a cutoff. Reads the * denormalized columns only — no JSON parsing; served by full scan bounded by created_at (small windows). */ sourceSummary(sinceMs: number): Promise>; /** 用量扫窗(usage-analytics):按时间窗取 {owner,created_at,status,result} 四列,行内投影 * stats(JSON 提取在 JS,免 SQL JSON 方言分叉);LIMIT+1 探测截断(触顶=truncated:true 诚实上抛)。 * 聚合在 src/usage-analytics.ts 纯函数(三后端同代码)。可选 owner 过滤=JWT 主体只看自己。 */ usageScan(fromMs: number, toMs: number, opts?: { owner?: string; limit?: number; }): Promise<{ rows: UsageRow[]; truncated: boolean; }>; /** Earliest retained event seq for a task (0 if none) → `retainedFrom` for /turns and the 416 boundary for * /stream (a resume point below this has been evicted past retention). */ retainedFrom(taskId: string): Promise; /** Events with seq strictly greater than `afterSeq` (use 0 for the whole stream). */ getEvents(taskId: string, afterSeq: number): Promise; /** * Mark stale `running` rows as failed (best-effort recovery for runs whose instance died), then * free any active-run claim whose run is no longer running (reaped here, or a terminal whose * DELETE was lost). `olderThanMs` must exceed the longest expected gap between a run's events * (each event refreshes `updated_at`), else a healthy slow run is wrongly reaped. */ reapStale(olderThanMs: number): Promise; /** * Durable F4 expiry: an approval parked beyond `olderThanMs` (the approval TTL) was never answered → fail * the run + RELEASE its task_active (unlock the session). Mirrors the checkpoint reaper's CAS-expire (≈ deny); * idempotent across replicas. Returns count. * * 🔴 [ref] D-D (blocker fix): SKIPS any row whose checkpoint is STILL pending — that row is * owned by a checkpoint-state-driven sweep (the SLA deny-sweep for human/irreversible_ask, or * failSuspendedWithExpiredCheckpoint once the checkpoint is expired). Without this guard, this time-based * UPDATE fired at the SAME instant as the deny-sweep (deadline == createdAt + ttl, both keyed on the TTL), * almost always committed FIRST (it is a single UPDATE), and deleted task_active + failed the run BEFORE the * deny-sweep's resumeCheckpoint ran — so resumeCheckpoint saw no task row (markResuming skipped) yet STILL ran * the model on a now-unlocked session (the pending checkpoint's resolve-CAS wins), orphaning a model leg and * opening a two-runs-one-session window. The NOT-EXISTS-pending guard (same shape as * failSuspendedWithExpiredCheckpoint) means this only ever reaps a row whose checkpoint is already gone/expired. * * 🔴 Alias-qualification divergence (kept EXPLICIT, never "fixed" to match): TiDB's SET clause QUALIFIES with * `tr.` (`SET tr.status = …`); PG's SET clause must NOT be alias-qualified (`SET status = …` — PG rejects * `SET a.col`). Both WHERE clauses use the `tr.` alias. */ reapSuspended(olderThanMs: number): Promise; /** * [ref] §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `terminal_at_ms` sweep): fail a * suspended run whose durable checkpoint was ALREADY EXPIRED by `reapExpired` (past its `deadline` or its * absolute `terminal_at_ms`), and release its task_active (unlock the session). Runs UNCONDITIONALLY (no * approval-TTL gate), CHECKPOINT-STATE-driven not time-driven, so it aligns EXACTLY with the per-row * `terminal_at_ms` (which never falls before an operator's >30d deadline) — unlike a uniform timer, which would * either be inert (gated off at APPROVAL_TIMEOUT_SEC=0) or prematurely kill a long gate. Safe against the two * windows a naive predicate mis-fires on: (a) the transient suspend-write window — a run suspended before its * checkpoint row exists has NO expired checkpoint, so it is not matched; (b) a re-suspended session that minted * a NEW pending checkpoint — the `NOT EXISTS pending` clause excludes it. Idempotent across replicas. Returns count. * * Same alias-qualification divergence as {@link reapSuspended} — see its header note. */ failSuspendedWithExpiredCheckpoint(): Promise; } /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */ export declare class TiDBRunStore extends SqlRunStore { constructor(pool: MySqlPool); } /** PostgreSQL binding — historical class name + ctor shape preserved. */ export declare class PgRunStore extends SqlRunStore { constructor(pool: PgPool); } //# sourceMappingURL=run-store-sql.d.ts.map