import { type SemaProvenanceInput } from "../trace/sema-provenance.js"; import type { SystemInjectionPriority } from "@sema-agent/core"; import type { LedgerEventType } from "../trace/ledger-events.js"; /** One pending completion, scoped to the session that must be told about it. `summary` is ALREADY * redacted + length-bounded by core's notifier seam (see {@link WorkflowCompletionPayload}). */ export interface WorkflowCompletionInboxEntry { sessionId: string; /** The originating run's owner (verified principal), or null in the single-user turnkey posture. Gates * emission at drain time (defence-in-depth over the sessionId→owner binding a delete/reclaim can rotate). */ owner: string | null; runId: string; status: "completed" | "failed"; summary: string; enqueuedAt: number; /** The frame family this entry drains as. Absent (every pre-1.109 row) = the original * `workflow_complete`. `task_notification` = a background subagent/bash completion observed while the * originating leg was IDLE (core's in-process injection had no harness to steer — the durable half * is the deployment's job). `runId` then holds the core task_id (disjoint id spaces). */ kind?: "workflow_complete" | "task_notification"; /** The task_notification extras (JSON: {task_type, toolUseId?, result?, output_file?}) — bounded + * redacted AT ENQUEUE (the store carries only egress-safe bytes). Absent on workflow_complete entries. */ payload?: string; } /** Session-keyed queue of finished-workflow completions awaiting a push to the originating session. * Two-phase delivery (1.80, closes the 1.77-review "drained-but-append-failed" at-most-once window): `pending` * READS without removing; the consumer removes each entry via `ack` only AFTER its frame durably landed (or it * decided a deliberate drop, e.g. an owner mismatch). A crash/failed write between pending and ack re-delivers * on the next leg (at-least-once; the shell dedups by runId). Two legs racing the same session may double-emit — * same at-least-once contract, same dedup. */ export interface WorkflowCompletionInbox { /** Record a completion for `entry.sessionId`. Idempotent on `runId` within a session (a re-delivered * at-least-once notify replaces, never duplicates). Dropped (with a warn) when the session was PURGED within * the fence window — a late completion racing a session delete must not resurrect as a live ledger entry * (1.78-review purge-vs-enqueue hygiene). */ enqueue(entry: WorkflowCompletionInboxEntry): Promise; /** READ every pending completion for `sessionId` (oldest-first) WITHOUT removing — phase 1 of delivery. */ pending(sessionId: string): Promise; /** Remove ONE delivered (or deliberately dropped) completion — phase 2. Idempotent on an unknown runId. */ ack(sessionId: string, runId: string): Promise; /** Drop every pending completion for `sessionId` without emitting (called on session delete) + arm the * enqueue fence for {@link PURGE_FENCE_MS}. `purgedOwner` (the deleted session's owner) scopes the fence: * a completion enqueued for a DIFFERENT owner — a new tenant who legitimately re-claimed the sessionId * within the window (sessionIds are caller-chosen; reuse is a supported flow) — is NOT fenced (review: * the ownerless fence silently starved the new claimant's push). Omitted `purgedOwner` = conservative * fence-all (an ownerless caller can't distinguish). */ purge(sessionId: string, purgedOwner?: string | null): Promise; /** The originating session OBSERVED this run's terminal state through a model-facing poll * (core 1.232 `WorkflowCompletionNotifier.ackServed` fires this), so the push would be * a pure duplicate ("poll-then-also-notify" double delivery: the model burns turns dismissing a completion * it already summarized). Drops the pending entry (a deliberate ack) AND arms a {@link SERVED_FENCE_MS} * enqueue fence for this (session, runId) so a LATE-arriving notify (the notifier races the poll) is * dropped too. Fence is in-memory only — a crash between poll and notify re-delivers (at-least-once; the * shell's own runId dedup + the model tolerate it), mirroring the purge fence's restart posture. */ markTerminalServed(sessionId: string, runId: string): Promise; } /** Cap on retained pending completions per session (drop-oldest beyond it). A session with more than this many * undelivered workflow completions is pathological; the poll floor still covers the dropped ones. */ export declare const MAX_PENDING_PER_SESSION = 100; /** Compact the File ledger (rewrite from live state) once this many records have been appended since the last * compaction — bounds on-disk size + boot replay cost for a long-lived process with high workflow churn. */ export declare const COMPACT_EVERY = 512; /** How long after a `purge` an `enqueue` for the same session is refused (the purge-vs-enqueue fence): a * workflow completion landing AFTER its session was deleted must not resurrect as a live entry the next * claimant of that sessionId would see. In-memory only (a restart forgets the fence — the cross-restart race * window is negligible and the drain-side owner gate still stands as the real tenancy boundary). */ export declare const PURGE_FENCE_MS: number; /** How long after {@link WorkflowCompletionInbox.markTerminalServed} an `enqueue` for the SAME (session, * runId) is refused — covers the notify path lagging the model's poll (the notify gate is async + * at-least-once, so the completion can land in the inbox AFTER the poll already told the model). Long enough * to swallow any realistic notify lag incl. a boot-recovery re-fire racing a fresh poll; per-runId scoped, so * it can never starve a DIFFERENT workflow's push. In-memory only (see {@link PURGE_FENCE_MS} rationale). */ export declare const SERVED_FENCE_MS: number; /** [ref] §4 server 折叠键:`JSON([域, taskId, RAW status])[#seq]`。域=external 帧独立 lane(task_id 是 * 调用方自由串,不分域可预占内部帧键把真实子代 settle 帧吞掉)/其余内部帧统一 "task" 域。 * * 三路复审修1(MED):status 保留 RAW,不再归一 completed/failed —— 归一让同 id 无 seq 的两条 external * 通知(先 `event` 后 `cancelled`)折叠成同一把 `…:failed` 键,inbox map 里第二条 REPLACE 第一条,drain * 只吐一条(entry 的存储 status 列仍归一,那是账本的双终态列,不动;RAW status 本就随 payload extras * 透传)。这与 {@link taskNotificationStreamKey} 的 RAW 语义对齐(stream 侧早已保 RAW)。 * * 编码=防碰撞元组:域/taskId/status 走 JSON 数组(调用方自由串自造不了分隔符);`#seq` 尾折在 JSON 之外 * ——JSON 段恒以 `"]` 结尾,`#digits` 尾无歧义,于是 {@link foldKeyFamily} 的剥尾语义 AND SQL 双生子 * (workflow-run-store-sql 的 `regexp_replace(run_id, '#[0-9]+$', '')` 家族比对)原样成立——把 seq 折进 * JSON 元组会让 SQL 侧家族 ack/fence 永 miss(那是另一批的真库改动面,本修有意不碰)。 * seq 折入=逐停机周期各自成键(1.283 起 background_agent 四腿 notify 铸 cycleSeq);无 seq=单周期旧键 * (bash/monitor terminal/retain 降级,诚实语义)。serve-fence 侧必须用同一函数(或其家族前缀)构造, * 否则键形漂移=fence 永 miss(server.ts fleet 腿的 `taskNotificationFoldKey({task_id, status})` 无 seq 形 * 即家族前缀,两侧同为 RAW status,依旧咬合)。 */ export declare function taskNotificationFoldKey(n: { task_type?: string; task_id: string; status: string; seq?: number; }): string; /** [ref] stream 侧去重键(core [ref]④a + codex 1.189 前双审 F2)= `域:taskId:status[#seq]`。 * 与 {@link taskNotificationFoldKey} 同族,域二分也一致(codex F2 HIGH:external 通知的 task_id 是 * POST /v1/sessions/:id/notify 调用方自由串——同一条腿上,drain 出的 external 帧可与内部任务帧同形 * `task_id:status:seq` 撞键互吞,per-leg 键集并不豁免这个面,旧注释「没有跨调用方预占面」判错); * 仍与 fold key 有意不同的一点:status 保留 RAW(killed/cancelled/event 不归一 failed)——归一会让同 * task 的 Monitor event 批与 failed 终态在一条腿上互吞;wire 去重要的是逐帧身份,不是 inbox 的双终态列。 * `seq` 折入 = 停→唤→停的第二周期同 status 帧各自成键(core 1.283 起 background_agent 四腿 notify 铸 * cycleSeq;缺 seq 时旧键曾把二周期 completed 帧在 observer 臂反向吞掉——core [ref]④a);无 seq 的帧 * (bash/monitor terminal/retain 降级)保持单周期旧键=同键第二帧吞,诚实语义。[ref]B-10:core 4.0.0 起 * task_type 是必填闭合枚举——`task_type?` 可选性**不再对应旧 core 帧**,它活着只为家族前缀构造 * (server.ts fleet 腿 `taskNotificationFoldKey({task_id,status})` 同款用法);缺席归 "task" 域的语义 * 只在前缀用途下生效。external 帧三来源齐备(notify 端点 core 铸 / inbox entry extras 存 / drain 帧 * spread 复原),不会漏域。observer 臂 / stream 臂 / turn-open drain 登记三处必须用本函数 * 构键且都传 task_type,键形漂移=去重永 miss。 */ export declare function taskNotificationStreamKey(n: { task_type?: string; task_id: string; status: string; seq?: number; }): string; /** Per-leg notified-key ledger cap (codex 1.189 前双审 F3):一条超长腿(海量互异 bg 通知)不许把键集 * 撑到无界——超出删最老插入(Map 迭代序=插入序)。远高于任何真实 per-leg 通知量;被逐键的老通知 * 最坏情形=同键晚到帧多写一次(at-least-once,shell 按键 dedup 兜底),绝不吞新帧。 */ export declare const NOTIFIED_KEYS_MAX = 4096; /** * Per-leg delivered-notification ledger(codex 1.189 前双审 F1 HIGH 的修位)。 * 键 = {@link taskNotificationStreamKey};值 = 「认领这把键的那次写是否真落盘」的 promise(true=成功)。 * 为什么是 Map> 而不是 Set:bg/resume 腿的 observer 臂是 fire-and-forget append * (`void append(...)`)——旧 Set 下 append 失败(SQL blip)时键已被占而盘上无帧,core pend-drain 把同 * payload 重放到 stream 臂时被键吞 = 通知永丢。现在 stream 臂对已见键 `await` 认领方的真实结果,false 则 * 照写并把键值升为 true。sync 腿的写是同步 res.write(无异步失败窗口)、drain 臂的写被 * emitPendingWorkflowCompletions await(失败→不 ack→下腿重投)——两者登记 resolved(true) 即可;三腿统一 * 用本 Map 形状,让三对臂保持逐行孪生。 */ export declare class NotifiedKeys { private readonly byKey; /** The pending/settled outcome of the write that claimed `key`, or undefined when unclaimed. */ get(key: string): Promise | undefined; /** Claim (or update) `key` with the outcome of its write. Bounded: past {@link NOTIFIED_KEYS_MAX} the * oldest-inserted key is dropped (F3; an update keeps the original insertion slot — good enough for a * pathology cap). */ set(key: string, written: Promise): void; } /** 家族前缀 = 折叠键只剥 `#seq` 分量(仅当尾是纯数字才剥)。新元组键的 JSON 段恒以 `"]` 结尾、taskId/ * status 里天然的 # 被 JSON 引号包住,`#digits` 尾无歧义=必是 seq;旧格式遗留行(`域:task:status#seq`) * 与 workflow_complete 的裸 runId 键同一语义原样兼容。 */ export declare function foldKeyFamily(runId: string): string; /** In-memory inbox — the parity oracle + the ephemeral opt-out (WORKFLOW_RUN_STORE=memory). No persistence: * a restart forgets undelivered completions (the poll floor covers them). */ export declare class InMemoryWorkflowCompletionInbox implements WorkflowCompletionInbox { protected readonly onWarn?: ((msg: string, meta: Record) => void) | undefined; protected readonly bySession: Map>; /** sessionId → {at, owner}: purge stamps; enqueues within {@link PURGE_FENCE_MS} for the SAME owner are * refused. Swept on BOTH enqueue and purge (a purge-heavy, enqueue-quiet process must not accumulate * stamps unboundedly — review). */ private readonly purgedAt; /** servedKey(sessionId, runId) → stamp: terminal-poll-served fences; an enqueue for the pair within * {@link SERVED_FENCE_MS} is refused (the poll already told the model). Swept alongside the purge fence. */ private readonly servedAt; constructor(onWarn?: ((msg: string, meta: Record) => void) | undefined); private sweepFence; /** True (and warns) when `entry`'s session was purged within the fence window AND the fence applies to its * owner: same owner as the purged session (a late completion of the DELETED session) — or an owner-less * fence (caller couldn't say), conservative. A DIFFERENT owner = a new legitimate claimant of a reused * sessionId → not fenced (their push must not be starved; the drain-side owner gate stays the real * tenancy boundary either way). */ protected purgeFenced(entry: WorkflowCompletionInboxEntry): boolean; /** Fence FIRST (an enqueue racing the poll is refused even mid-call), then drop any already-pending * entry via `ack` — the durable subclass inherits this and gets a durable ack for free. */ markTerminalServed(sessionId: string, runId: string): Promise; protected stampPurge(sessionId: string, owner: string | null | undefined): void; /** Fold one entry into the map. `warn` is false during ledger replay (a historical overflow must not re-log * on every boot) and true on a live enqueue. */ protected applyEnqueue(entry: WorkflowCompletionInboxEntry, warn: boolean): void; enqueue(entry: WorkflowCompletionInboxEntry): Promise; pending(sessionId: string): Promise; ack(sessionId: string, runId: string): Promise; purge(sessionId: string, purgedOwner?: string | null): Promise; } /** File-backed crash-safe inbox (the durable default — a background workflow's completion survives a replica * restart, the prerequisite for pushing it to a session that reconnects after the crash). */ export declare class FileWorkflowCompletionInbox extends InMemoryWorkflowCompletionInbox { private readonly fsyncEnabled; private readonly dir; private readonly ledgerPath; private fd; private appendsSinceCompact; constructor(root: string, onWarn?: (msg: string, meta: Record) => void, fsyncEnabled?: boolean); private replay; private commit; /** Compact iff we've appended enough since the last one. MUST be called AFTER the in-memory mutation the * triggering commit records — compact() rewrites from `bySession`, so a compact fired BEFORE the caller * applied its enqueue/delete would rewrite a stale snapshot and lose that record. */ private maybeCompact; /** Rewrite the ledger from the authoritative in-memory state — one `enqueue` record per LIVE pending entry — * dropping every accumulated drain-tombstone + evicted/drained line. Atomic (temp + rename) so a crash * mid-compact leaves the old ledger intact. */ private compact; enqueue(entry: WorkflowCompletionInboxEntry): Promise; ack(sessionId: string, runId: string): Promise; purge(sessionId: string, purgedOwner?: string | null): Promise; /** Close the ledger fd (shutdown). */ close(): void; } /** * P1 ①② delivery half, TWO-PHASE since 1.80: read the session's pending completions, emit * one `workflow_complete` out-of-band frame per entry, and `ack` (remove) each entry only AFTER its emit * SUCCEEDED — a failed frame write (dead socket / durable-append error) leaves the entry pending, so the next * leg re-delivers instead of silently eating it (the 1.77-review "drained-but-append-failed" window, closed). * Called at STREAM-OPEN of EVERY run leg (the sync `POST /v1/runs` SSE leg emits a live frame; the bg + resume * legs append a durable `workflow_complete` event their tailing client replays). Best-effort throughout (the * deterministic `WorkflowStatus` poll is the floor); at-least-once — the shell dedups by runId (two legs racing * the same session may double-emit; same contract). * * 🔒 Tenancy (defence-in-depth over the authorizer's sessionId→owner gate): only entries whose stamped * `owner` matches `callerPrincipal` are EMITTED — a session deleted-then-reclaimed by a different tenant * (which rotates the sessionId→owner binding) can't surface the prior owner's completions. `owner: null` * (single-user turnkey) has no tenancy boundary → always emitted. A non-matching entry is a DELIBERATE drop — * acked (the session is gone/reclaimed for its original owner; the poll floor is their fallback), so it never * wedges the queue. */ export declare function emitPendingWorkflowCompletions(inbox: WorkflowCompletionInbox | undefined, sessionId: string | undefined, callerPrincipal: string | null, emit: (frame: { type: LedgerEventType; }) => void | Promise, /** Emit-TARGET diagnostics — WHICH leg drained (route) onto WHAT kind of connection * (live SSE write vs durable event-log append), plus the enqueue→emit lag, so a "workflow completed but the * shell only saw it on the next manual turn" report can be pinned to the leg that finally carried the frame * (the push is stream-open-driven by design — a session with NO open leg has no push path until its next * leg; the lag makes that visible instead of a mystery). Info-level, one line per delivered frame. */ diag?: { route: string; connection: string; log(msg: string, meta?: Record): void; }): Promise; /** * The durable half is the deployment's job: map a core `TaskNotificationPayload` * (background subagent / background bash completion) onto an inbox entry, for the IDLE gap — core's in-process * injection covers a LIVE harness; a completion that lands after the leg ended had NO durable path (idle no-show, * lost across restart). Bounded + redacted HERE (the store carries only egress-safe bytes; summary/result are * UNTRUSTED free text). `status` collapses onto the inbox's completed|failed column; the ORIGINAL status rides * the payload extras and overrides on drain (killed/cancelled reach the shell verbatim). Workflow-type * notifications are the CALLER's job to filter (they have their own durable chain — double-enqueueing here would * re-open the poll-served double-push). */ export declare function taskNotificationInboxEntry(sessionId: string, owner: string | null, n: { task_id: string; task_type: string; toolUseId?: string; status: string; summary: string; result?: string; output_file?: string; sessionId?: string; seq?: number; lines?: string[]; stoppedBy?: string; recentSteps?: Array<{ tool: string; target: string; outcome: string; }>; editedFiles?: Array<{ path: string; edits: number; }>; resumable?: boolean; /** core 1.353([ref]):诊断槽(journal 坐标+教句,引擎值单源)——park 透传,drain 帧原样带。 */ diagnostics?: string; /** [ref] [ref] 片6 d1(core 7.2.0):子代→主代 uplink 帧的渲染判别成员 + typed 归因侧记。park 是 * **空闲态**(父 leg 已收尾)到达的那条路——恰是 [ref](b) 的场景;不过桥 ⇒ drain 帧无判别键、cli 渲老形卡。 * body = UNTRUSTED 子代文本 → redact+cap(与 result 同姿势);provenance 成员逐挑 verbatim(引擎值)。 */ agentMessage?: { from: string; body: string; }; _sema_provenance?: SemaProvenanceInput; }, enqueuedAt: number, /** lead-b (dual-ID audit): the PARENT run's durable taskId (the uuid the shell's footer rows * key on) — rides the drained frame so a consumer can bind the a*-keyed child notification to its uuid-keyed * parent row without a side lookup. */ parentTaskId?: string, /** * [ref] §3.6(core 5.61.0,[ref] / codex R1[high]):core 在 `onTaskNotification` 的**第二参**上报的 * 投递档位。park 是**跨 run** 的那条投递路径 —— 不带过去,档位就在最常走的一条路上恒缺席。 * * 🔴 只在 core **真的给了**档位时传:缺席原样缺席(与 live 投影同一条「缺席≠later」纪律)。 * 闭集之外的值当作缺席(下面的 `INJECTION_TIERS` 守卫)——durable 载荷更不该出现引擎从没发过的词。 * ⚠️ 本仓自己铸的 park(`POST /v1/sessions/:id/notify` 的 idle 半场)**不传**这一位:那条 park 是 * 纯展示帧,没有任何引擎侧的档位承诺,填一个词就是凭空铸一条投递声明。 */ priority?: SystemInjectionPriority): WorkflowCompletionInboxEntry; /** * Resolve WHERE a workflow completion routes (core 1.208): the SESSION comes straight off * `originatingSessionId` (the Runner closes over it at RunWorkflow mount — lookup-free). The OWNER comes from, * in order: * 1. the originating RUN ROW's owner (`getRun(sourceTaskId)`, 1.75) — immutable, stamped at submit. Only the * BG leg has a run-row `sourceTaskId` though: the sync + resume legs deliberately leave `spec.taskId` unset * (subagent nesting), so core derives `sourceTaskId = sessionId` and this lookup MISSES for them (review * review, 1.78) — which under the old rule silently killed the multi-tenant push for every sync-leg workflow; * 2. the payload's `principal` — core threads `spec.principal`, which in THIS service is ALWAYS the VERIFIED * identity (the 1.55 F-fix invariant, main.ts: gated door = BFF-verified header, direct door = JWT `sub`, * never the self-asserted header; core 1.187 hardened the `TaskSpec.principal` contract to exactly this). * Captured at SPAWN time → immutable, no delete-then-reclaim window. This is also the same value core's own * workflow scope guard (`canAccessWorkflowRun`) keys off; * 3. neither → single-user stamps `owner: null` (no tenancy boundary); multi-tenant (`requirePrincipal`) * returns undefined = DROP fail-closed (an unverifiable owner must not ride the reclaim gate; the * `WorkflowStatus` poll floor covers). Returns undefined when there is no route at all. * The `getRun` lookup also carries the session FALLBACK for a crash-RECOVERY notify payload that lacks * `originatingSessionId` — since core 1.210 the durable `WorkflowRun` records it and the recovery * re-derivation threads it through (workflow-notify-journal), so this fallback covers only pre-1.210 rows * (or a writer that omitted it), not the recovery path wholesale. */ export declare function resolveCompletionRoute(p: { originatingSessionId?: string; sourceTaskId?: string; principal?: string; }, getRun: ((taskId: string) => Promise<{ sessionId?: string | null; owner?: string | null; } | undefined | null>) | undefined, requirePrincipal: boolean): Promise<{ sessionId: string; owner: string | null; } | undefined>; /** * core 1.232 — resolve WHICH session's inbox entry a poll-served ack targets: the payload's * `originatingSessionId` directly (core threads it on `ackServed`), else the originating run row via * `sourceTaskId` (mirrors {@link resolveCompletionRoute}'s session half — same fallback, same reason: * older runs may predate the lookup-free field). Returns undefined when there is no attribution — the * entry stays pending (at-least-once; the shell's own dedup covers a rare duplicate). */ export declare function resolveServedSession(p: { originatingSessionId?: string; sourceTaskId?: string; }, getRun?: (taskId: string) => Promise<{ sessionId?: string | null; } | undefined | null>): Promise; //# sourceMappingURL=workflow-completion-inbox.d.ts.map