/** * [ref]/[ref]①/[ref] 追加件:409 `conflict.session_active_run` 的**真出路材料**装配(单源)。 * * 事故形:park(suspended/needs_review)占着 session claim,客户端每条新提交吃 409,而旧文案只教 * cancel(毁灭当前 run)。对 parked 会话,「继续」的唯一合法动作是去**对的** resume 入口决议——但四条 * resume 入口各认各的 gate.kind,客户端不知道等的是哪种门,只能试错。本模块把答案放进 409 响应体: * activeTaskStatus(区分「插话/取消」与「决议/取消」两族出路) * pendingGate { kind, decidePath }(parked 且真有 pending checkpoint 时) * * 🔴 token 永不上 wire(approvals-assistant 纪律:resume 寻址=sessionId,checkpoint token 是秘密能力)。 * 🔴 失败方向:材料是 best-effort 增强——store 面任何失败都不得挡 409 本体,退化为旧形状,绝不 throw。 * 🔴 gateKind→入口映射是**这张表在仓里的唯一函数形**;它的知识此前散在 server.ts 三处 guard 文案里 * (`gate_not_tool_approval`/`gate_not_resumable`/`wake.gate_pending` 的指路句)。三处文案与本表若漂移, * test/session-active-conflict-materials.test.ts 的分门用例会红。认不出的 kind ⇒ null(诚实缺席,不铸假门)。 */ import { type Autonomy } from "../runtime-governance.js"; import type { ShellGateDoctrine } from "@sema-agent/core"; import { type RunRecord } from "../plugins/store-contracts.js"; import { type PendingRowBinding } from "../plugins/checkpoint-store-sql.js"; /** [ref] 归因的部署侧入参(两个治理旋钮;判据属主见 `governanceMandatesShellGateAlways`)。 */ export interface GovernancePosture { autonomy?: Autonomy; manualModeShellGate?: "always" | "classify"; } /** 与 runs.ts/tasks.ts 三个 409 位共享的旧文案(byte-frozen:api-error-text-freeze 门认这句)。 */ export declare const ACTIVE_RUN_CONFLICT_BASE_TEXT = "session already has an active run \u2014 POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)"; /** * 409 体(与 SSE done 帧 result 共用)里的**待批门材料**。 * * `kind`/`decidePath` = 出路(去哪决议);[ref] 追加的 `governanceForced` = **出身**(这道门是谁下的)—— * [ref] T3「park 后待批通道事件」的读者此前只知道「有一道 X 型门」,不知道它是运维治理层强制的还是 * 别的来源,于是「我都开了 bypassPermissions 为什么还在问」这个 UX 缺口在 park 腿上原样复现 * ([ref]#1/[ref] 在活卡腿上的实证)。 * * 🔴 **additive + 只在为真时在场**:与活卡帧 `ToolApprovalFrame.governanceForced` **同一条纪律**—— * 缺席 ≠「这不是治理门」,而是「没有治理来源的证据」(判据够不着的形也落在缺席里,见装配处的注)。 * * [ref] 追加的 `checkpointId` = **相关性**(这道门在别的面上叫什么名字),是出路/出身之外的第三个问题。 * 上文那条「askId 为什么不在这里」的记账(见 {@link governanceOriginOf} 顶注末段)说的是**同一个缺口的 * 另一半**:park 门此前在 wire 上**没有任何稳定身份**——唯一单射的键是 `token`,而 token 是秘密能力、 * 永不上 wire,于是消费端只能拿 `(sessionId, kind)` 这种非单射组合去猜「这次的门」和「上次的门」是不是 * 同一道。core 5.42.0 的 `Checkpoint.checkpointId`(`mintCheckpointId` 铸)正是 * 为此存在的**非秘密孪生身份**(core 顶注逐字:identification vs capability, separate axes;独立铸而 * **不是** token 摘要,理由是「最需要它的投影面恰恰是拿不到 token 的那些」——本载体就是其中之一)。 * ⚠️ 它**不是** askId 的替代:askId 是 `approval_ask` 行的身份,checkpointId 是 checkpoint 行的身份, * 两者一对多且今天仍无反查读口(那条记账原样有效,别据本键认为它已销账)。 * ⚠️ **辖域如实**:本批只把这一位投到**本载体**(409 体 + 其 done 帧)。另外三条 park 读面今天都**没有** * 这一格,而且它们的**代价不同**(亲核过读面真码,别按一句「都补上」估工): * · `/v1/assistant/inbox` 读 `listByScope` ⇒ core 的 `CheckpointSummary`,**值已经在手**(core * `summarizeCheckpoint` 单源投影里就带着),补它 = 那张显式白名单上加一行,零 SQL 面; * · `GET /v1/approvals` 与 `/v1/approvals/stream` 读的是**本仓自己的** `PendingCheckpoint` * (`checkpoint-store-sql.ts` 的 `listPending`,**按列 SELECT、刻意不拉 blob** —— 那是它避免读放大 * 的既定姿势),所以这一位**不在手**:要补得给 checkpoint 表反范式一列(与 `gate_kind` / * `bound_input_hash` / `risk_descriptor` 同族)⇒ **SQL 面 = 双库集成门**,不是顺手一行。 * 补它们是另一批的事(各自过三问:谁需要跨面 join、join 不上谁受伤、补偿是什么),别在文档或消费码里 * 预支「两面能对上」。 * * 🔴 **keyset 门辖域外,故在此成文**:`src/trace/core-keyset-guard.ts` 的七个面是 * TaskNotificationPayload / BackgroundChildEvent / RosterEntry / AskRequest+ToolApprovalFrame / * TaskEvent / MailboxMessage / TraceEvent —— `Checkpoint` / `CheckpointSummary` **不在其中**(本载体 * 读的是结构形入参,不是 core 类型),所以 core 给 checkpoint 面加 additive 键时本文件**不会** tsc 红。 * 判据在此:本材料是**显式白名单**(逐键铸,不 spread 整行),新键要进 wire 必须有人在这里写一行。 */ export interface PendingGateMaterial { kind: string; decidePath: string; /** 见 {@link PendingGateMaterial} 顶注:`true` 才在场,恒不写 `false`。 */ governanceForced?: true; /** [ref](core 5.42.0):这道 park 门的**非秘密稳定身份**(`Checkpoint.checkpointId`)。逐字过境, * server 绝不自铸、绝不派生(信任边界校验见 `safeCheckpointIdOf`)。 * 🔴 **缺席是三成因合流的一个形**(R3-[low] 验真后改真 —— 此前这里只写了第一条,那是失真): * ① **legacy 行**:该键诞生前 park 的(core 明写「absent ⇒ legacy row, read compatibly, no backfill」); * ② **服务端校验拒绝**:行上有值但没过 `safeCheckpointIdOf`(空串 / 超长 / 含 token) ⇒ **数据污染**, * 而它在运维面上今天**与 ① 不可分辨**(见该函数顶注末段的残留登记); * ③ **材料读取失败**:整只 `pendingGate` 都不在(那时本键连位置都没有)。 * ⇒ 读作「不知道这道门叫什么」,**不是**「这道门没有身份」,更**不是**「这一定是条老行」。 * * 🔴 **归属不确定性(codex 交叉复审 R1-[high] 曾如实登记)—— [ref]([ref])已收口**:同 session 可以 * 并存两条 pending checkpoint(表上**没有** per-session pending 唯一约束;并存形 = 终局后的 `task_done` * 纯 park + 下一条 run 的门 park),而 `findPendingTokenBySession` 曾是**无序 `LIMIT 1`**,本材料的 * 每一格因此曾共享「读到的是哪一行」的不确定性。现形:本模块按 **run 绑定**选行(`LIVE_RUN_GATE_KINDS` * —— activeTaskId 持有 claim ⇒ 它的门绝非 task_done),且店读口自此有序(最新优先,token 兜平局; * 谓词/序属主 `checkpoint-store-sql.ts` `pendingRowQuery`,LOCAL twin 同判)⇒ 同一情形连续两次 409 * 报的是同一行。残余如实记:这一位仍不是**跨 run** 的去重键 —— activeTaskId 换了,门自然也换。 */ checkpointId?: string; } export interface ActiveRunConflictBody { error: string; errorCode: "conflict.session_active_run"; activeTaskId: string | null; activeTaskStatus?: string; /** [ref] S1([ref]):running 形专属的**活性证据**——本副本 ledger sink 最后一次 durable append 距今 * 的毫秒数(「turn 真在推进」的判别材料;与行 updatedAt 的进程心跳语义刻意分离)。**同副本 * best-effort**:缺席 = 无法证明(跨副本/重启后),不是「不活」——诚实缺席,绝不铸 0。消费端 * (cli running 臂,[ref] C3)凭它区分「真忙的后台 run」与「久无进展的疑似僵局」,替代猜测文案。 */ msSinceLastActivity?: number; /** * [ref](c)([ref]② 成文的洞 (c) 的修):**两面口径的对齐位**。 * * 病:poll 面(`GET /v1/runs/:id`)对「`running` 且 `now - updatedAt > runStaleSec`」的行**折叠**成 * `status:"failed"`(+「run stalled (instance lost?)」),而本体把 run 行 `status` **原样**报 `running`。 * ⇒ 自愈窗内(心跳 30s 断供 + `REAP_RUN_STALE_SEC` 默认 120s + `REAP_INTERVAL_SEC` 默认 60s ⇒ SQL 车道 * 上界 ≈180s;LOCAL/file 车道无周期腿,上界 = ∞)同一条死 run 两面自相矛盾:409 说「还在跑,去 * steer/cancel」,poll 说「已经 failed」。壳只能靠猜。 * * 🔴 **刻意不学 poll 面谎报终态**:409 的 `activeTaskStatus` 是 run 行的**真**状态,而且这条 run 的 * session claim **确实**还攥着(reaper 还没跑);把它折成 `failed` 会造出「行说 failed 但锁还在、 * 新提交照旧 409」的第二种自相矛盾。改用 additive 的判别位:`stale:true` 与既有 `activeTaskStatus` / * `msSinceLastActivity` **并存**,壳凭它把两面对上(「poll 那边会说 failed = 这条大概率是死行」)。 * * 🔴 **只在为真时在场**(与 `PendingGateMaterial.governanceForced` 同一条纪律):缺席 = 「没有陈旧的 * 证据」,**不是**「证明它活着」——判据只有 `updated_at` 一维,慢而活的 run 有误杀窗([ref]② 洞 (b) * 原样有效)。⇒ 这一位是**分诊材料**,不是终态断言。 * * 🔴 **只对 `running` 铸**:park 两态(suspended/needs_review)根本不在 `reapStale` 射程内([ref]② * 洞 (a)),给它们标 stale 就是假材料。 */ stale?: true; pendingGate?: PendingGateMaterial; } /** gate.kind → 它的那一个 resume 入口(sessionId/taskId 寻址,无秘密)。 */ export declare function resumeEntryForGate(kind: string, ids: { sessionId: string; taskId: string; }): string | null; export declare function governanceOriginOf(gate: { riskDescriptor?: { shellGateDoctrine?: ShellGateDoctrine; }; } | undefined, governance: GovernancePosture | undefined): true | undefined; /** token 泛型:真身是 branded CheckpointToken(秘密能力,只在本函数内部流转,绝不进响应体)。 */ /** * SSE 车道的 done 帧 result(拒绝形)。与 409 body **同一铸体处** —— 此前 tasks.ts 在 res.write 里 * 手搓内联对象挑键,形状没有名字、没有类型、只活在那一行字符串里(conditional-spread 死键的老坑形)。 * 这里铸,SDK 的 done 帧 result 类型与本形同车对齐(sdk-api-guarantee-duty),live-contract 套件对已发包真跑。 */ export interface ActiveRunConflictDoneResult { status: "failed"; /** [ref]C-1:与 409 体同一机器码——流腿客户端凭它认拒绝,禁按 byte-frozen 文案匹配。 */ errorCode: "conflict.session_active_run"; errorMessage: string; activeTaskId: string | null; activeTaskStatus?: string; /** [ref] S1:与 409 body 同键同语义(见 {@link ActiveRunConflictBody.msSinceLastActivity})。 */ msSinceLastActivity?: number; /** * [ref](c):与 409 body 同键同语义(见 {@link ActiveRunConflictBody.stale})。 * * 🔴 **跨仓欠账,如实登记**(codex 交叉复审 R1-[medium] 验真后的真形):sema-sdk 的 * `spec/openapi.yaml` 里 `ActiveRunConflictDoneResult` 是 `additionalProperties: false` 的**封闭** * schema,今天只声明 `status/errorCode/errorMessage/activeTaskId/activeTaskStatus/pendingGate` 六键。 * ⚠️ 亲验后的关键修正:这**不是本键引入的新问题** —— `msSinceLastActivity`([ref] S1)早就在这条 * done 帧上,同样没进那张封闭表,即**同族违例已存在**且没有任何门看得见它(producer-contract 只校 * 409 体那条 op,不校这条 SSE 帧)。本批**刻意不**为了绕开它而让 done 帧与 409 体分家(「同材料禁 * 分家」是本文件自己的纪律,分家换来的是两面口径再次相左 —— 正是本件在修的病)。 * ⇒ 跟车件已上报:sema-sdk 侧把 `msSinceLastActivity` + `stale` 一并补进该 schema,并给 * producer-contract 加一条**真** SSE 冲突 done 帧的校验格(今天那条空白正是两键都能悄悄漂出去的原因)。 */ stale?: true; pendingGate?: PendingGateMaterial; } export declare function toDoneFrameResult(body: ActiveRunConflictBody): ActiveRunConflictDoneResult; interface ConflictProbeDeps { /** 结构形入参,但 `status` 取 run 行的**闭词表**(合并码重扫):判 park 要走词表属主 * `isParkedRunStatus`,而它的入参是穷举联合 —— 这一格写成开放 `string` 就只能在调用点裸 cast, * 绕开那道编译期执法(core 加一个 park 类成员时本文件不会红)。 */ /** `updatedAt`([ref](c)):stale 折叠的唯一判据维([ref]② 洞 (b):慢而活的 run 有误杀窗)。可选 —— 缺席 * 或不可解析 ⇒ `stale` 键诚实缺席(与本模块「材料是 best-effort 增强」同方向)。 */ runStore?: { getRun?: (id: string) => Promise<{ status?: RunRecord["status"]; updatedAt?: string; } | null | undefined>; } | undefined; checkpointStore?: { peekPendingScope?: (sessionId: string, binding?: PendingRowBinding) => Promise; findPendingTokenBySession?: (sessionId: string, scope?: string, binding?: PendingRowBinding) => Promise; /** 结构形(不 import core 的 `Checkpoint`):只声明本模块**真读**的几格。`riskDescriptor` 是 * core 在 mint 时挂到升级门上的判读元数据(display/triage only),`shellGateDoctrine` 是 2026-08-05 * 裁定加的取证格 —— 见下方 `governanceOriginOf` 的推导论证。`checkpointId`([ref],core 5.42.0) * 是行的非秘密稳定身份,可选 —— legacy 行没有它(见 `PendingGateMaterial.checkpointId`)。 */ get?: (token: TToken) => Promise<{ checkpointId?: string; gate?: { kind?: string; riskDescriptor?: { shellGateDoctrine?: ShellGateDoctrine; }; }; } | null | undefined>; } | undefined; /** [ref]:出身归因的部署侧一半(缺席 ⇒ 归不出治理出身 ⇒ 键缺席 —— 与 best-effort 同方向)。 */ governance?: GovernancePosture | undefined; /** [ref](c):stale 折叠的阈值(秒),生产装配 = `config.runStaleSec` —— 与 poll 面 * (`routes/runs.ts` 的 `deps.config.runStaleSec * 1000`)**同一个旋钮**,两面口径因此同源。 * 缺席/非正数 ⇒ 不折叠(键缺席),等价于「本部署没有配这条判据」。 */ runStaleSec?: number | undefined; /** [ref] S1:turn 活性读口(生产装配 = `readTurnActivityMs`,turn-activity.ts)。返回最后活性时刻 * (epoch ms)或 undefined。可选:未装配/读不到 ⇒ `msSinceLastActivity` 键缺席,best-effort 同方向。 */ turnActivity?: ((taskId: string) => number | undefined) | undefined; } export declare function buildActiveRunConflict(deps: ConflictProbeDeps, sessionId: string, activeTaskId: string | null | undefined): Promise; export {}; //# sourceMappingURL=active-run-conflict.d.ts.map