import type { ReadEntry } from "../tools/fs/safety.js"; import type { RepairBundle } from "../agents/repair-loop.js"; import type { WorkspaceHandle } from "./remote-env.js"; import type { ConsolidationNote } from "./runner/memory-consolidation.js"; import type { TaskResult } from "./types.js"; /** * design/45 — the **durable-checkpoint** primitive: cross-process / resumable `suspend`/`resume`. * * A {@link CheckpointStore} is the third pluggable durable seam (alongside `ToolResultStore` and * `MemoryStore`): a token-addressable store of "enough state to resume a suspended task", with an * **atomic CAS `resolve`** so a token is acted on **exactly once** even across replicas / double * approvals. Two profiles share this one seam (design/43 原语 4, proven by two service instances): * * - **F4** (`gate.kind === "human"`) — a running task hit a tool-policy `ask` that no in-process * approver can answer (headless / long / cross-replica), so it persists a checkpoint and returns * `status:"suspended"`. Later `runner.resume(token, {gate:"policy_ask", decision})` continues it. * This is the cross-process version of the synchronous `onAsk` gate (design/37). * - **1C** (`gate.kind === "task_done"`) — design/38 Path A persists a background sub-task handle here * so a caller can `get` its outcome across replicas. v1 stores the handle only; the parent does NOT * mid-task suspend (Path A unchanged) — `runner.resume()` does NOT serve this gate in v1. * * **opt-in, default-off**: with no `CheckpointStore` wired (and no durable approval mode requested), a * policy `ask` still resolves the 1.63 way (synchronous `onAsk` / headless auto-deny) — behavior * unchanged. Default {@link InMemoryCheckpointStore} is process-only (single instance / tests); a * durable backend (service's `tidb-approval-store` / `tidb-run-store`) makes it cross-replica. * * See `design/45-durable-checkpoint原语-suspend-resume.md`. */ /** * A high-entropy, single-use checkpoint token. Branded so it can't be confused with a `sessionId` / * `taskId` (council Question #2): the token doubles as the resume capability (token-as-auth, §6), so a * mix-up would be a security bug, not just a type slip. Mint with {@link mintCheckpointToken}. */ export type CheckpointToken = string & { readonly __brand: "CheckpointToken"; }; /** Mint a CSPRNG 128-bit checkpoint token (token-as-auth, §6/Q5): unguessable, never logged/in-URL. */ export declare function mintCheckpointToken(): CheckpointToken; /** * Who resumes a checkpoint, and how the resume `outcome` is interpreted. The `kind` is the discriminant * that {@link ResumeOutcome} must match at the resume entry (council #3: prevents a `task_done` gate * being resumed with a `policy_ask` outcome → type confusion / corruption). */ /** design/74: why a resource-slice suspend fired. `"budget"` covers cost OR tokens (both are checked by * `overBudget`); `"walltime"` is the soft slice deadline; `"turns"` the slice turn cap. * design/80 Seam #2: `"preempt"` is NOT a resource limit — it is an EXTERNAL scheduler yield (the scheduler * raised `spec.preemptSignal` to free resources for a higher-priority task). It rides the SAME durable-suspend * mechanism: the gate.kind stays `resource_limit` (the mechanism), so the resume path is byte-identical to a * budget/turns/walltime resume (continue the work, no decision); `"preempt"` is only the CAUSE. */ export type ResourceLimitReason = "budget" | "walltime" | "turns" | "preempt"; /** * design/80 D-2: which SAFETY MARK(s) on the tool caused an `ask` to mint an {@link CheckpointGate} * `irreversible_ask`. Derived from the tool's STATIC spec marks (`ToolSpec.egress` / `ToolSpec.irreversibility`, * round-1 council fix — NOT the per-call `decisionReason`, which missed a policy/hook pre-ask and was * forgeable). Persisted on the gate so a network budget/escalation resolver reads WHY this is a safety ask FROM * THE DURABLE RECORD (never re-derives risk from a model-controlled value — the model self-reports nothing * here). Both can be true (a tool marked BOTH egress AND irreversible). */ export interface SafetyAxis { /** design/70: the tool is egress-marked (`ToolSpec.egress` — an external write: push, open PR, send). */ egress?: boolean; /** design/77 §4: the tool's irreversibility tier is `always` or `maybe` (`ToolSpec.irreversibility`). */ irreversible?: boolean; } /** * design/80 §D-E: a DETERMINISTIC, REDACTED risk summary attached AT MINT to an escalation * {@link CheckpointGate} (`human` / `irreversible_ask` — the tool-call approval escalations) so a supervisor * INBOX can sort/triage by TRUE severity without re-deriving risk. **Display/triage metadata ONLY** — core * NEVER reads it to gate / budget / suppress anything (structural ask strictly wins via `combinePolicies`; * no mint suppression reads it). The profile/inbox READS it; core only ATTACHES it. Pure function of the * call (no clock/random) — same call ⇒ identical descriptor. */ export interface RiskDescriptor { /** ToolEmu-style severity tier 1..5 (5 = most severe). The inbox sorts DESC by this. Deterministic — the * pure {@link riskSeverity} of {@link axes}. The ORDERING is what matters (the inbox's triage key). */ severity: 1 | 2 | 3 | 4 | 5; /** Which safety axes tripped — for `irreversible_ask`, derived from the D-2 {@link SafetyAxis} (+ the * shell-gate). `shell` marks a coarse shellGate tighten (a bash command gated only because the deployment * set `shellGate`, with NO explicit per-tool egress/irreversible mark). Empty `{}` for a plain budgetable * `human` ask. Self-contained so the inbox needn't cross-ref `safetyAxis`. */ axes: { egress?: boolean; irreversible?: boolean; shell?: boolean; }; /** The tool whose call is gated (mirrors the gate's `toolName`). */ toolName: string; /** A REDACTED, length-capped ONE-LINE summary of the call (the command for a shell gate; a brief key-arg * digest otherwise) for the inbox preview. NEUTRALIZED via {@link import("./untrusted-text.js").inlineUntrusted} * + length-capped (it is persisted + shown to a human inbox, so a malicious tool arg carrying a * `` variant / newline / huge string must NOT inject into the render or bloat storage). * NEVER raw secrets / full args / env. Deterministic. Optional (omitted when nothing safe to summarize). */ summary?: string; /** Best-effort file paths the action touches (fs-tool `path` args; shell parsing is deliberately NOT * attempted — over-reaching a shell parse risks a wrong/forgeable path). Each path `inlineUntrusted`-capped. * Omitted when none derivable. */ touchedPaths?: string[]; } /** * design/80 §D-E: the DETERMINISTIC severity tier (1..5) for an escalation checkpoint, a PURE function of the * tripped {@link RiskDescriptor.axes} (NO LLM, NO clock/random) so the inbox's sort order is stable and * unit-testable in isolation. The ORDERING is the contract; the absolute numbers map to ToolEmu's 5 tiers: * - irreversible AND egress → **5** (the most severe — an external, irreversible write) * - irreversible only → **4** * - egress only → **3** * - shell-gated tighten → **3** (a coarse shellGate ask with no explicit egress/irreversible mark — the * command MAY be benign, so it is not auto-graded as high as a marked tool) * - plain `human` ask → **2** (no safety axis tripped — a budgetable approval) * - (1 reserved — least-severe / informational; not expected from these gates.) * `egress` dominates the shell coarse-grade (an explicitly egress-marked shell tighten is still ≥3). A future * arm may refine this — but only with a stated reason, and the ORDERING must stay monotone in risk. */ export declare function riskSeverity(axes: { egress?: boolean; irreversible?: boolean; shell?: boolean; }): 1 | 2 | 3 | 4 | 5; /** design S1e (service [204]): the char cap for the {@link CheckpointSummary.toolInput} BOUNDED raw preview of a * `tool_approval` pendingAction's `args` (`JSON.stringify`-ed). Caps the `listByScope` payload size; over-cap * is truncated with a trailing `…`. Bounded raw (NOT neutralized) — redaction is the consumer's job (echo-only). */ export declare const MAX_TOOL_INPUT_PREVIEW_CHARS = 512; /** * design/80 §D-E: build the DETERMINISTIC, REDACTED {@link RiskDescriptor} for an escalation checkpoint at * MINT. Pure — a function ONLY of (`toolName`, `args`, the D-2 `safety` axis, the `shellGated` flag); it reads * NO clock/random, so the SAME call ⇒ an IDENTICAL descriptor (pinned by a test). * * **Determinism contract = plain-DATA args** (the real flow: model-JSON / hook-rewritten plain objects). A * Proxy whose `ownKeys` trap returns a DIFFERENT key set per call is OUT OF CONTRACT — JS cannot detect a Proxy * (codex review Item 2), so its (display-only) digest may vary. This NEVER affects a security/budget/mint * decision: `riskDescriptor` is INERT (no core path reads it to gate), so an out-of-contract input can at worst * degrade an inbox preview, never a permission outcome. * * **Redaction is load-bearing** (the `summary`/`touchedPaths` are PERSISTED + surfaced to a human inbox): * every model-controlled value goes through {@link inlineUntrusted} (folds CR/LF/Unicode separators to one * space, defuses `` variants + `<<<`/`>>>` fence sentinels) AND a length cap, so a * malicious arg carrying a break-out tag / newline / huge string can neither inject into the inbox render nor * bloat the durable row. NEVER dumps full args / env / secrets — only a bash command string or a short * `name=value` digest of the SHOWN args. * * `summary` for a shell gate = the `command` string (capped 200 cp); otherwise a `name=value` digest of the * call's top-level string/number/boolean args (each value capped), omitted when nothing safe to summarize. * `touchedPaths` reads ONLY the obvious fs `path` arg (read_file/edit_file/write_file) — shell parsing is * deliberately NOT attempted (over-reaching a shell parse risks a wrong/forgeable path, so OMIT for bash). */ export declare function buildRiskDescriptor(input: { toolName: string; args: unknown; /** The D-2 {@link SafetyAxis} threaded to the mint (egress/irreversible), or `undefined` for a plain ask. */ safety?: SafetyAxis; /** True ONLY when this is a `bash` call gated coarsely by `shellGate` (no explicit per-tool egress/irreversible * mark) — drives the `shell` axis + severity-3 coarse grade. */ shellGated?: boolean; }): RiskDescriptor; export type CheckpointGate = /** F4: a human (or any external authority) must allow/deny a pending tool call. design/80 §D-E: * carries an OPTIONAL display-only {@link RiskDescriptor} (severity/axes/summary) for the supervisor * inbox to triage by — INERT (core never reads it to gate/budget). */ { kind: "human"; reason: string; toolName: string; riskDescriptor?: RiskDescriptor; } /** design/77 §4 (Gate 4) + design/80 D-2: a PRE-ACTION human approval before a SAFETY-tightened tool runs — * an IRREVERSIBLE tool (send money/email, file a return) OR an EGRESS tool (push, open PR, send). Same * approval family as `human` (a pending tool call to allow/deny → suspendRef → `status:"suspended"`, resumed * with a `policy_ask` outcome). **design/80 D-2 (load-bearing):** minted whenever an `ask` is for a tool * carrying the egress / irreversibility (`always`/`maybe`) SAFETY MARKS — regardless of how the ask arose * (a gate tighten OR a policy/hook that already asked; round-1 council fix: keying on the per-call * `decisionReason` missed the policy-ask case AND was forgeable) — **EVEN WHEN `durableApproval` is wired** * (`durableApproval` supplies scope/ttl, NOT the kind). The DISJOINT-from-`human` kind is what a * network budget resolver keys on to NEVER auto-approve a safety ask (a budget may only down-budget a plain * `human` ask). {@link SafetyAxis} records which axis(es) tightened. NOT a dry-run review. */ | { kind: "irreversible_ask"; reason: string; toolName: string; safetyAxis?: SafetyAxis; riskDescriptor?: RiskDescriptor; } /** design/74: a resource slice limit (budget/walltime/turns) was reached — suspend (resumable) instead of * fail. There is NO pending tool to adjudicate; resume just continues the run with the next slice's * allowance (sized from {@link Checkpoint.resourceLedger}). The matching {@link ResumeOutcome} arm is * `{ gate: "resource_limit"; decision: "continue" }`. */ | { kind: "resource_limit"; reason: ResourceLimitReason; } /** design/76 §2.5 (dry-run / shadow): a POST-PREDICTION REVIEW pause. A profile's dry-run interception ran a * predicted action and produced a buffered state-diff a human (or judge) must REVIEW before it is applied — * so the task suspends to the durable `needs_review` TERMINAL (`TaskStatus:"needs_review"`) instead of * finishing. **DISJOINT from the approval family** (`human`/`irreversible_ask`): those are PRE-ACTION * *approvals* (suspendRef → `status:"suspended"`, resumed with a `policy_ask` outcome); this is a * POST-prediction *review* (reviewRef → `status:"needs_review"`, resumed with a `dry_run_review` outcome). * The split is load-bearing — reusing the approval family would make assemble-result report `"suspended"` * and the `needs_review`/`review.pending` branch dead code (v4 MAJOR-A). It is ALSO disjoint from the * `RepairTerminal.needs_human_oracle` (design/78) — a different type space; never cross-use the two. * The state-diff itself is a PROFILE concern (a REF, not stored here — design/76 §9 / §2.5); core only * owns the gate/status/discriminant plumbing. NOT a pre-action approval. */ | { kind: "needs_review"; reason: string; } /** design/80 D-B (plan-gate): a PRE-ACTION human PLAN REVIEW. Before an agent acts on a high-blast-radius * plan (a profile's plan-gate fires on shell⇒gate + an irreversible/egress/finance mark present + a * blast-radius trigger — NOT every task), the run pauses so a human can **approve / edit / reject the * proposed PLAN** before any step runs. It is its OWN gate kind (r2 ruling — do NOT reuse `needs_review`, * whose gateMatch arm forces a `dry_run_review` outcome; reusing it would make a plan resume a * type-confusion mismatch), but it shares the **review-pause** machinery: it routes to `reviewRef` + * `status:"needs_review"` (a human-review pause), resumed with a `plan_review` outcome. * * **THREE-WAY SPLIT (load-bearing):** * - the APPROVAL family (`human`/`irreversible_ask`) → PRE-ACTION *approvals* of a pending TOOL CALL * (suspendRef → `status:"suspended"`, resumed with a `policy_ask` outcome bound to a tool call); * - `needs_review` → a POST-prediction *review* of a buffered state-diff (reviewRef → * `status:"needs_review"`, resumed with a `dry_run_review` outcome); * - `plan_review` (this) → a PRE-ACTION *review* of a PLAN (reviewRef → `status:"needs_review"`, resumed * with a `plan_review` outcome). It binds NO tool call — the human reviews the PLAN, not a pending arg — * so it carries no `tool_approval` pendingAction (its pendingAction is `{kind:"plan_review"}`, with no * tool fields) and the `policy_ask` decision-action binding machinery never touches it. The plan/diff * artifact is a PROFILE concern (a REF, not stored here — design/76 §9); core owns the * gate/status/discriminant plumbing only. NOT a pre-action approval, NOT a dry-run review. */ | { kind: "plan_review"; reason: string; } /** 1C: the checkpoint tracks a background sub-task's completion (design/38 Path A). v1 = handle only. */ | { kind: "task_done"; }; /** * The outcome a caller supplies to `runner.resume(token, outcome)` — a **discriminated union** keyed by * `gate`, validated against `checkpoint.gate.kind` at the entry (council #3). **v1 implements only the * `policy_ask` arm**: `task_done` is a dead branch under 1C Path A (the caller orchestrates, never * mid-task `resume()`), typed here for completeness and the v2 join-suspend option. */ export type ResumeOutcome = { gate: "policy_ask"; /** * design/80 D-1 (decision-action binding): the `toolCallId` the human actually saw/approved. The * resume is REJECTED (`checkpoint.invalid_outcome`, fail-closed, pre-CAS) unless it matches the * checkpoint's pending tool call (`pendingAction.toolCallId`) — this closes the TOCTOU re-suspend * wrong-apply: a stale `yes` minted against pending call X must not resolve a DIFFERENT pending call * Y (approve vendor-A $5k applied to vendor-B). The decision must name the action it bound to; the * runner never trusts that the caller matched the correct pending call (design/77 doctrine — * deterministic structural backstop, not a model self-report). * * The correct value is the resumed checkpoint's `pendingAction.toolCallId`; the caller has it via the * `suspendRef` / pending record it is answering. */ boundCallId: string; /** * design/80 D-1 §2 (slice 1a.2): the **server-minted opaque** hash of the pending tool call's input * that the human actually saw/approved (`PendingAction.tool_approval.boundInputHash`, computed by the * engine at suspend-mint via {@link import("./canonical-json.js").boundInputHashOf} over the post-hook * `args`). The caller echoes it VERBATIM — it is opaque; the SDK/service NEVER re-canonicalize args * (red-team r3: a second runtime's serialization could diverge → false mismatch → fail-closed a * legitimate approval). The resume verifies it by **string equality** against the checkpoint's * persisted value (`checkpoint.invalid_outcome`, fail-closed, pre-CAS) — closing the TOCTOU "same call * id, different input" variant that `boundCallId` alone misses (a re-mint that swapped the input under * the same tool-call id). It binds the SHOWN input, NOT `updatedInput`: an `allow` edit is the same * operator's authorized rewrite, applied AFTER this binding check (design/37,末位应用), so the echoed * hash is always the pending record's value regardless of any edit. (Legacy pre-1a.2 checkpoints have * no persisted hash → the resume skips this check, binding on `boundCallId` alone; new mints enforce.) */ boundInputHash: string; /** `allow` → execute the pending tool call; `deny` → inject a denial result and continue. */ decision: "allow" | "deny"; /** A re-written arg payload (design/37 policy `allow` rewrite); re-validated on execute. */ updatedInput?: unknown; /** Model-readable reason attached to a `deny` (else a default is used). */ reason?: string; } /** design/74: continue a resource-suspended run with the next slice's allowance. NO decision payload and * NO budget figure — the allowance is computed from {@link Checkpoint.resourceLedger}, so money never * reaches the caller or the model. Matches `CheckpointGate.kind === "resource_limit"`. */ | { gate: "resource_limit"; decision: "continue"; } /** design/76 §2.5 (dry-run / shadow): resolve a `needs_review` suspend after a human/judge reviewed the * buffered predicted state-diff. `decision:"approve"` → the profile applies the buffered diff (atomically, * invalidating `readFileState` on touched paths — design/76 §2.5; the apply itself is PROFILE, not core) * then continues; `decision:"reject"` → the prediction is discarded and the run continues without it. Kept * DISJOINT from `policy_ask` (the approval family) so the resume-side discriminant never confuses a * post-prediction review with a pre-action approval (the third `needs_review`/`dry_run_review` gateMatch arm). * No money/diff payload reaches the caller here — the diff lives in the profile's REF store (design/76 §9). */ | { gate: "dry_run_review"; decision: "approve" | "reject"; reason?: string; } /** design/80 D-B (plan-gate): resolve a `plan_review` PRE-ACTION plan pause after a human reviewed the * proposed PLAN. `decision:"approve"` → proceed with the plan as-is; `decision:"edit"` → proceed with the * human's `editedPlan` (a TYPED sibling of the plan — NEVER a raw tool `updatedInput`; a plan is not a tool * arg, so this binds NO action and never enters the `policy_ask` boundCallId/boundInputHash machinery); * `decision:"reject"` → the model RE-PLANS (the rejected plan is discarded; a `reason` may steer the * re-plan). Kept DISJOINT from `policy_ask` (approval) AND `dry_run_review` (post-prediction review) so the * resume-side discriminant never confuses the three (the `plan_review`/`plan_review` gateMatch arm). Like * the other non-`policy_ask` outcomes it **binds to no action** (winnerFromOutcome → undefined); the * `resolvedOutcome`/binding machinery is for `policy_ask` only. No plan/diff payload (beyond the human's * `editedPlan` text) reaches core — the plan artifact lives in the PROFILE's REF store (design/76 §9). */ | { gate: "plan_review"; decision: "approve" | "edit" | "reject"; editedPlan?: string; reason?: string; } | { gate: "task_done"; result: TaskResult; }; /** * The pending action a checkpoint suspends *before* — a **discriminated union** keyed by `kind`. Every * consumer that reads the tool fields MUST branch on `kind` first: the `resource_limit` arm (design/74) * has none (a resource slice suspend has no pending tool to resolve). * * `tool_approval` (§4): a tool call adjudicated `ask` in durable mode, captured with its **post-hook args** * (the design/37 rewrite already applied) so resume executes the exact same call without re-running * PreToolUse hooks (§11 M2). `batchToolCallIds` / `completedCallIds` capture the **mid-batch** position * (§4.ter): one assistant turn can emit a batch of tool calls; if call #k hits `ask`, calls #1..k-1 already * executed (in `completedCallIds`) and #k+1..N are still pending. ID-based (a `Set`), not * positional — immune to reordering / off-by-one (council Question #1), and it doubles as the reconcile * suspended-batch discriminant (§15.2 net-add #7) so wake-reconcile never closes a suspended call. */ export type PendingAction = { kind: "tool_approval"; toolCallId: string; toolName: string; /** Post-hook (design/37-rewritten) args to execute on `allow`. */ args: unknown; /** * design/80 D-1 §2 (slice 1a.2): the server-minted **opaque** boundInputHash of {@link args} — a * SHA-256 (hex) via {@link import("./canonical-json.js").boundInputHashOf}, computed ONCE here at * suspend-mint and persisted on the row. The operator sees it (surfaced on the pending record) and * echoes it back as {@link ResumeOutcome} `boundInputHash`; the resume verifies opaque string equality * against THIS value (never re-serializing the args — see the field's doc). Absent only on a legacy * pre-1a.2 checkpoint (deserialized without it), in which case the resume skips the hash check. */ boundInputHash: string; /** All tool-call ids in the suspending assistant message, in emission order. */ batchToolCallIds: string[]; /** Ids of calls already executed (results in the session) when the suspend fired — #1..k-1. */ completedCallIds: string[]; } /** design/74: suspended at a resource slice boundary — there is NO pending tool to resolve. Consumers * that read the tool-approval fields above MUST branch on `kind` first (this arm has none). */ | { kind: "resource_limit"; reason: ResourceLimitReason; } /** design/80 D-B (r3 — an EXPLICIT arm, NOT a reused `resource_limit` placeholder): paused at a PRE-ACTION * PLAN REVIEW (`CheckpointGate.kind === "plan_review"`). There is NO pending tool to resolve — the human * reviews the PLAN, not a tool call — so this arm has no tool fields. Every consumer that reads the * `tool_approval` fields above MUST branch on `kind` FIRST: a `plan_review` checkpoint must never flow into * a `resource_limit`- or `tool_approval`-shaped continuation (a contract test pins this). The plan artifact * is a PROFILE concern (a REF — design/76 §9); core stores no plan here. */ | { kind: "plan_review"; }; /** * The per-task correctness state a checkpoint must carry so a resumed task runs in the **same state * space** it suspended in (§4.bis, the jury head must-fix). An **explicit whitelist** of serializable * correctness fields — never a blind `JSON.stringify(Prepared)`, which would silently corrupt the 9+ * non-serializable runtime objects (`harness`/`session`/`abortController`…) it holds (round-2 BUG#1). * * Round-trip fidelity of every field is covered by tests; runtime objects are **forbidden** here. * `cacheFingerprint` (design/31) is deliberately **excluded** — it is observation-only, so resume just * suppresses cache-break detection on the first turn rather than persisting it as correctness state. */ export interface CheckpointState { /** design/36: deferred-tool activation set — else resume re-discloses / diverges from history. */ activeTools: string[]; /** 1.41 submit_output: the validated structured output set pre-suspend, else it's lost on resume. */ outputRef?: { value?: unknown; set?: boolean; }; /** design/38: nested sub-agent cumulative cost — else pre-suspend child cost evaporates (§4.bis/Q7). */ nestedStats: { tokens: number; turns: number; tasks: number; costUsd: number; costMicroUsd: number; }; /** design/41: consolidation notes collected pre-suspend — else the task-end pass loses them. */ consolidationNotes?: ConsolidationNote[]; /** design/44: the hand's read-file state (content hashes), serialized from the hands-toolkit closure * (NOT part of Prepared — §15.2 net-add #8). Without it a resumed `edit_file` is rejected "not read". */ readFileState?: Array<[string, ReadEntry]>; /** * design/78 Slice-1: the SAFE-tier self-repair loop's durable state (`failureTrace`/`diagnostics`/ * `rejectedHypotheses`/`attemptCount`/`oracleTier` — all JSON/`structuredClone`-safe, no fn/Date). Set ONLY * when an orthogonal durable suspend (resource/HITL) interleaves a `runRepairLoop` run — the happy path is * in-memory only. On resume it re-seeds `RepairLoopConfig.resumeBundle` so `attemptCount` advances * MONOTONICALLY (never reset). `baselinePassTests` is deliberately NOT carried here (grader-computed * out-of-process — a worker must not be able to shrink the ratchet). Absent for a run with no repair loop. */ repairBundle?: RepairBundle; /** * design/49 v1.5: the remote workspace's serializable identity (E2B sandbox id / provider / mount path / * snapshot id). Set ONLY when the suspended task ran with a remote, suspendable {@link WorkspaceHandle} * (a per-task `executionEnvFactory` env that was `suspendVM()`-paused rather than destroyed). Resume * rebuilds the env via the factory and `resumeVM(snapshotId)` so the restored workspace matches the * `readFileState` above. All-string fields → JSON/`structuredClone` round-trips safely (consistent with * this whitelist's "no runtime objects" rule). Absent for a process-local (non-remote) suspend. */ workspaceHandle?: WorkspaceHandle; /** * design/80 D-A: a durable mid-task STEER for a DURABLY-SUSPENDED task. Live `TaskStream.steer` * (runtask.ts) is unreachable while the harness is idle (durably suspended), so a human supervisor's * guidance is parked HERE via {@link CheckpointStore.setPendingSteer} and injected on resume (runtask.ts, * after the resume-continuation prompt). It is GUIDANCE ONLY — never an approval channel (§3 inv #4) and * never parsed into control state (§3 inv #5); a budget/autonomy/gate-threshold is CONFIG, not steer text. * * `trusted` is FROZEN at `setPendingSteer` from the SERVICE's verified-principal check (an operator-role * check, NOT a client header — the service's job, out of scope here) and NEVER recomputed on resume * (§3 inv #1). On resume a `trusted:false` steer reaches the model as a PLAIN user message with NO * `` wrapper (no authority laundering, §3 inv #3); a `trusted:true` steer MAY ride the * reminder (`formatHookFeedback`, mirroring the live trusted branch). `text` containing `` * is REJECTED at `setPendingSteer` (typed `steering.invalid_content`) so a dirty steer never enters this * state (§3 inv #2); the untrusted-injection path ALSO sanitizes the text as untrusted data, belt-and-braces. * Absent when no steer is pending. All-string fields → JSON/`structuredClone` round-trips safely. * * **Delivery is BEST-EFFORT, at-most-meaningfully-once (review-council, by design):** a steer is GUIDANCE, * not a correctness-critical message, so the delivery guarantee is intentionally loose: * - It rides EVERY resume of THIS checkpoint that runs a turn — including a faithful `env_failed`/ * `tool_unavailable` reopen→re-resume, which re-shows the guidance (coherent with the reopen REPLAYING the * leg; the model re-does the work, re-seeing the steer). It is NOT carried onto a NEW re-suspend checkpoint * (serializeCheckpointState stamps `undefined`) — a supervisor steers the new checkpoint afresh. * - It is DROPPED (never delivered) on a resume that runs no turn (an exhausted-budget resume) or that is * set in the get→resolve race window of an in-flight resume — both rare; the run is ending or the steer * just missed its train. A supervisor re-issues `setPendingSteer` if a steer didn't land. * A precise exactly-once delivery would need clearing the steer from the persisted row on consume (a reopen * clear + a get→resolve interlock); deferred as not worth the cross-backend complexity for guidance text. */ pendingSteer?: { text: string; trusted: boolean; }; /** * SR-7 (CC 2.1.198 orphaned-background-task notice: F6c pretty.js:698391-698398, resume leg * :707384-707399 under the `CLAUDE_CODE_RESUME_INTERRUPTED_TURN` gate — the cloud-worker restart * leg, exactly sema's durable-resume shape): the background tasks (pending/running, this run's * owner triple) still ALIVE at suspend. Background processes never survive a suspend (design/103 * §3.7 unconditional dispose + 飞轮 [506]③ killed receipts), so on resume any snapshot entry NOT * alive in the resume leg's registry is an ORPHAN — aggregated into ONE CC-verbatim "The container * was restarted…" reminder appended to the resume continuation (single message: header + * `- description (task id)` list + re-create instruction; never a per-task barrage). A survivor * (session-resident shell / in-window monitor) is excluded — still reachable via TaskOutput/ * TaskStop, it needs no obituary. CC's per-orphan `Ku(task_id,"stopped")` status write (:707399) * is N/A here: the suspend teardown already settled them killed with receipts. Absent for a run * with no live background tasks at suspend, and for pre-1.262 checkpoints (deserialized without * the field) — both resume silently, exactly like CC with an empty list. */ runningBackgroundTasks?: Array<{ id: string; description?: string; }>; /** * codex 终审 1.255 F2: the hands band's LOGICAL working directory at suspend (`handsCwdRef.current` — * moved by `cd` and by EnterWorktree). Without it a resume silently reset the task cwd to the task root: * relative Read/Edit/Write paths and Bash commands then operated somewhere else than the model believes. * Absent when the task has no tracked cwd (no real shell / read-only hands). The directory itself is * plain on-disk state that survives a process-local suspend (worktrees included — the tree stays on disk). */ handsCwd?: string; /** * codex 终审 1.255 F2: the ACTIVE EnterWorktree session at suspend (worktree.ts keeps it in a shared * serializable ref, not a closure-only var, precisely so it lands here). Without it a resume LOST the * session: ExitWorktree became a no-op (the unchanged worktree could never be removed), a second * EnterWorktree was wrongly accepted, and `handsCwd` pointed into a worktree the tooling no longer * owned. All-string fields; the worktree directory survives the suspend on disk (managed under the * task root, `git worktree` metadata intact). */ activeWorktree?: { worktreeDir: string; originalCwd: string; baseSha: string; entered?: boolean; }; } /** * {@link CheckpointState} with **every** field made required-PRESENT, while each value keeps its original * type (so an absent optional field is still passed explicitly as `undefined`). The suspend-side * serialization builds `state` as this type (design/45 P3 / design/51 §P3): adding a new per-task * correctness field to {@link CheckpointState} is then a **compile error** at the serialization site until * it is explicitly handled — it can never be silently omitted and lost on resume. (Keying off * `keyof Required<…>` makes the mapped type non-homomorphic, so it forces presence of every key yet leaves * each value's `| undefined` intact — `workspaceHandle: undefined` for a process-local suspend still * type-checks.) */ export type SerializedCheckpointState = { [K in keyof Required]: CheckpointState[K]; }; /** * The current {@link Checkpoint} schema version a v1.5 suspend stamps, and the highest a resume here * accepts (design/49 §3, code-ready council round-2). Bump together with `MAX_SUPPORTED_CHECKPOINT_VERSION` * only when this worker can also *read* the new shape. A future v2 worker raises MAX to 2 and still reads a * v1 checkpoint — forward-compatible by `<=`, never `=== `. */ export declare const CURRENT_CHECKPOINT_VERSION = 1; /** design/74 R3-B: the schema version a `resource_limit` suspend stamps (v2 — it adds `resourceLedger` and a * `resource_limit` `pendingAction`/`gate` an old worker can't handle). An old worker (`MAX_SUPPORTED`=1) * rejects it pre-CAS (stays `pending`, retryable on a new worker). */ export declare const RESOURCE_CHECKPOINT_VERSION = 2; /** * design/80 D-1 (version-skew downgrade fix): the schema version a **binding-bearing** human/irreversible_ask * `tool_approval` suspend stamps. These checkpoints carry the decision-action binding (`boundCallId` + * `boundInputHash`) whose enforcement lives ENTIRELY in the resuming worker's resume path. A pre-D-1 worker * (released 1.100.0: `MAX_SUPPORTED`=2, and its resume code has ZERO binding logic) would otherwise resume a * D-1-minted v1 checkpoint and execute the pending tool with NO decision-action verification — the exact * "approve vendor-A $5 → execute vendor-B $5000" bypass the binding exists to prevent (council BLOCKER #1). * Stamping these at **v3 (> the old worker's MAX of 2)** forces a pre-D-1 worker to reject them PRE-CAS * (`unsupported_version`, stays `pending`, retried on a binding-enforcing worker) instead of silently voiding * the binding. A resource_limit suspend keeps stamping v2; a pre-binding (legacy 1.100.0) checkpoint is v1. */ export declare const BINDING_CHECKPOINT_VERSION = 3; /** The highest {@link Checkpoint.version} `runner.resume` will act on; a higher one is rejected pre-CAS with * {@link CheckpointError} `unsupported_version` (the checkpoint stays `pending`, retryable on a newer worker). * Raised to 3 for design/80 D-1 binding checkpoints — this worker reads v1 (legacy human), v2 (resource), * and v3 (binding human/irreversible_ask). */ export declare const MAX_SUPPORTED_CHECKPOINT_VERSION = 3; /** * Read a checkpoint's schema version, defaulting an absent field to **legacy `0`** (a 1.67-era checkpoint * written before the field existed — it carries no `workspaceHandle`, so resuming it the v1 way is safe). * Compare with `<= MAX_SUPPORTED_CHECKPOINT_VERSION`, never `=== CURRENT_CHECKPOINT_VERSION` (design/49 §3). */ export declare function checkpointVersionOf(cp: Pick): number; /** * design/74 R3-B: cross-slice resource accounting carried by a `resource_limit` {@link Checkpoint}. The * total budget is the human's allocation; `spent*` accumulate across the resume→re-suspend chain (debited * at each suspend's `put`). A resumed slice's effective `maxCostUsd` = `min(window, totalBudgetMicroUsd - * spentMicroUsd)`; the final {@link TaskResult} stats aggregate `spent* + this slice's stats` so a * multi-slice run reports the whole, not just the last segment. Lives ON the checkpoint row so one * `resolve`/`put` CAS covers status + ledger atomically (no cross-store split). */ export interface ResourceLedger { /** The human's total $ allocation in micro-USD; `undefined` = no $ ceiling (only window/walltime bound). */ totalBudgetMicroUsd?: number; /** The human's total wall-clock allocation in seconds; `undefined` = no time ceiling. */ totalWalltimeSec?: number; /** Cumulative cost across all slices so far (micro-USD), debited at each suspend's `put`. */ spentMicroUsd: number; /** Cumulative tokens across all slices so far. */ spentTokens: number; /** Cumulative turns across all slices so far. */ spentTurns: number; /** How many slices have run so far (the resource chain's suspend count). */ sliceCount: number; } /** design/74 Slice 4: accumulate ONE slice's spend into the ledger — called when a `resource_limit` suspend * commits its checkpoint (debit-at-`put`, so the persisted ledger always reflects everything spent up to and * including the slice being suspended). Pure. The FIRST slice (no prior ledger) seeds the human totals from * `total`; every later slice keeps the ledger's own totals (authoritative — a resumed slice must not be able * to silently raise its own ceiling). Negative slice figures are clamped to 0 (a misreported usage can never * REFUND the cumulative spend and reopen budget). */ export declare function debitLedger(prior: ResourceLedger | undefined, slice: { costMicroUsd: number; tokens: number; turns: number; }, total?: { totalBudgetMicroUsd?: number; totalWalltimeSec?: number; }, opts?: { countSlice?: boolean; }): ResourceLedger; /** design/74 Slice 4: the $ budget (micro-USD) the NEXT slice may still spend = `totalBudgetMicroUsd - * spentMicroUsd`, never negative. `undefined` when no total $ ceiling is set (the run is bounded only by the * per-slice window / walltime). A resumed slice's effective `maxCostUsd` is `min(window, this)`. NB: `0` is a * VALID, exhausted ceiling — NOT "unlimited" (only `undefined` is unlimited). Any spend then immediately trips * `overBudget`, and the run loop fails an exhausted resume fast (runtask). Never falsy-test this value (a * `if (remaining)` would skip a legitimate 0 ceiling and silently bypass the budget). */ export declare function remainingBudgetMicroUsd(ledger: ResourceLedger | undefined): number | undefined; /** * design/80 D-1 (reopen-by-reason + persist-winner): the WINNING resume binding, recorded ON the * checkpoint row the moment a {@link CheckpointStore.resolve} CAS wins (pending → resolved). It is the * durable record of "which decision was approved against which pending action" so a later reopen→re-resume * can be validated against it. Only a `policy_ask` resolve records a winner (it is the only outcome bound * to a specific pending tool call); a `resource_limit` / `dry_run_review` / `task_done` resolve records * none (there is no action to bind). `boundInputHash` (binding the exact executed bytes) is a SEPARATE * later D-1 slice (1a.2) and is deliberately NOT part of this winner. */ export interface ResolvedOutcome { /** The pending tool call this decision was bound to (design/80 D-1 slice 1a). */ boundCallId: string; /** The adjudication the operator made. */ decision: "allow" | "deny"; /** A re-written arg payload, if the `allow` rewrote the call (design/37). Carried so an `env_failed` * re-resume (a system retry of the SAME approved action) must replay the identical `updatedInput`. */ updatedInput?: unknown; } /** * design/80 D-1: why a consumed checkpoint was {@link CheckpointStore.reopen | reopened} (resolved → * pending). The reason drives re-resume validation (§3 invariant #1): an `env_failed` reopen is a SYSTEM * RETRY of the ALREADY-APPROVED action — the re-resume MUST replay the persisted {@link ResolvedOutcome} * winner, never a new vote — while a `tool_unavailable` reopen (the action could not run, may now be * invalid — P-7) lets a human RE-DECIDE with the tool present, so a fresh operator decision IS allowed. */ export type ReopenReason = "env_failed" | "tool_unavailable"; /** * design/80 D-1 (atomicity fix): the optimistic-concurrency precondition a {@link CheckpointStore.resolve} * caller passes so its CAS is atomic with the validation it did against an earlier `get()` snapshot. The * resume-side decision-action guards (boundCallId/boundInputHash) bind to per-token-IMMUTABLE fields, but the * **reopen-by-reason** guard reads `reopenReason` + `resolvedOutcome` — which a concurrent * {@link CheckpointStore.reopen}/{@link CheckpointStore.resolve} cycle mutates. Passing the monotonic * {@link Checkpoint.rev} the caller observed makes `resolve` additionally require the LIVE row's `rev` to still * equal it; ANY intervening resolve/reopen bumps `rev`, so a cycle in the get→resolve window loses the CAS * (fail-closed), forcing a re-`get` + re-validate. * * **Why a counter, not the reopenReason value (round-2 BLOCKER fix):** `reopenReason` is a 2-valued enum, so a * full `env_failed`→`tool_unavailable`→`env_failed` cycle (the `tool_unavailable` arm permits a fresh P-7 * re-decision) returns `reopenReason` to the SAME value while `resolvedOutcome` silently changed to a different * (e.g. RETRACTED) winner — a classic ABA on the OCC key. A monotonic `rev` bumped on every resolve/reopen is * ABA-proof: a returned-to-the-same-value `reopenReason` still has a strictly higher `rev`. Omit `expect` * (legacy callers / direct store tests) → no OCC. */ export interface ResolveExpectation { /** The monotonic {@link Checkpoint.rev} the caller observed at `get()` (absent rev ⇒ legacy `0`). */ rev: number; } /** A persisted suspension point: enough to resume a task on any replica. `status` drives the 3-state * machine (pending → resolved | expired) that makes resume idempotent (§5). */ export interface Checkpoint { token: CheckpointToken; /** * Schema version of this checkpoint (design/49 v1.5). A v1.5 suspend stamps {@link CURRENT_CHECKPOINT_VERSION}; * absent ⇒ legacy `0` (1.67-era, no `state.workspaceHandle`). Resume rejects `> MAX_SUPPORTED_CHECKPOINT_VERSION` * pre-CAS so an old worker can't silently ignore fields a newer format relies on (forward-compatible by `<=`). * Read via {@link checkpointVersionOf}. */ version?: number; /** Multi-tenant isolation key (forced through `resolve`/`reap` WHERE, §2.1 service [4]). */ scope: string; /** The session to resume (via the SessionStore). */ sessionId: string; /** The session leaf the suspend happened at — the resume point (CAS write base, §5). */ leafId: string; gate: CheckpointGate; pendingAction: PendingAction; state: CheckpointState; /** design/74 R3-B: cross-slice resource accounting. Present for a `resource_limit` gate (debited at the * suspend's `put`; read on resume to size the next slice + aggregate the final stats). **design/80 D-E-core * (A3):** ALSO attached to a human/irreversible_ask APPROVAL suspend — debited with that leg's spend — so a * STATELESS policy can read durable cumulative spend across the resume chain via {@link ToolCallRequest.budget} * (a fresh in-memory counter would reset every leg). On the approval path it is READ-CONTEXT ONLY: the final * -stats aggregate fold (runtask) is gated to the `resource_limit` gate kind, so an approval resume is never * double-counted. Absent for `task_done`. See {@link ResourceLedger}. */ resourceLedger?: ResourceLedger; /** * Absolute epoch-ms deadline for an awaiting-human checkpoint; past it, `reap` expires it (§6). * * **design/80 §D-D semantic contract (the DURABLE FIELD a reaper-loop consumes — read it on * {@link CheckpointStore} too):** for an APPROVAL gate (`gate.kind` ∈ {`human`, `irreversible_ask`}) the * deployment's reaper-loop treats this as an **SLA resolve-deny** time (FACET A — its fast path: at deadline * it `resolve`s the checkpoint as a `deny` so the run ends cleanly), with a LATER `terminalAt` abandonment * backstop the loop ALSO honors (crash-safe if the SLA timer never fired). For an UNATTENDED-TTL gate * (`gate.kind` ∈ {`resource_limit`, `needs_review`, `plan_review`} — the resource + review-pause family) this * is an **abandonment-TTL** → `expire`/{@link reap} ONLY, NEVER a resolve-deny (FACET B — those kinds resume * only with their OWN outcome, so a `policy_ask` resolve-deny hits no `gateMatch` arm → `gate_mismatch`). The * reaper-loop + the gate.kind policy + minting `terminalAt` are the DEPLOYMENT's job * (its store impl + loop — owned service-side, channel [135]/[136]); CORE only guarantees this `deadline` * field (for `human`/`irreversible_ask`) + the persisted {@link gate}.kind survive the round-trip so any * reaper consumer (the service's TiDB loop OR a non-service Pg deployment) implements the SAME contract. */ deadline?: number; status: "pending" | "resolved" | "expired"; createdAt: number; /** * How many times THIS task has suspended, counting this checkpoint (1 = first suspend; design/72 * §2.2 (B)). Carried forward across resume→re-suspend (each new checkpoint = the prior count + 1), so * a restart-prone model that keeps re-issuing the gated/egress call — re-suspending every resume — * is capped: past `maxSuspends` the run fails (`suspend.loop`) instead of minting another checkpoint * and looping forever. Absent ⇒ legacy `0` (pre-§2.2 checkpoints; treated as "no prior suspends"). */ suspendCount?: number; /** * design/91 — the wall-clock instant (epoch ms, from {@link import("./types.js").RunnerDeps.now}) this task * suspended at a HUMAN-REVIEW gate. Resume derives the human-review latency for this leg as * `now() − suspendedAt` and folds it into `TaskResult.stats.humanReview` (the design/89 §2.4 C2 burden axis). * The human-review family is the APPROVAL gates (`gate.kind` ∈ {`human`, `irreversible_ask`}) — core stamps * this automatically on the approval-suspend mint — AND the REVIEW-PAUSE gates (`needs_review` = a dry-run * diff review, `plan_review` = a pre-action PLAN review). Review-pause checkpoints are minted by a PROFILE * (core owns only the routing/resume discriminants), so a profile that wants the C2 wait counted MUST stamp * `suspendedAt = deps.now()` at mint — the same injectable clock the resume reads, so a test pins the latency. * Absent on a `resource_limit` suspend (a machine backstop, not a human wait), on a review-pause checkpoint * whose profile did not opt in, and on a legacy pre-design/91 checkpoint (⇒ resume skips the durable latency * for that leg — no undercount surprise, just no entry). */ suspendedAt?: number; /** * design/91 — the accumulated {@link import("./types.js").TaskResult.stats}`.humanReview` from all PRIOR legs, * carried forward across resume→re-suspend (like {@link suspendCount} / {@link resourceLedger}) so a * multi-leg suspend/resume chain reports the WHOLE human-review burden, not just the last leg's. It holds * the gates resolved up to and including the suspend BEFORE this one — the latency for THIS suspend is added * by the resume that wakes it (`now() − suspendedAt`). Absent ⇒ no prior human-review time (a first suspend * with no earlier synchronous asks). Budget-EXCLUDED side observable — never a cost/gate input. */ humanReview?: { count: number; totalWaitMs: number; gates: Array<{ kind: string; waitMs: number; decision?: string; }>; }; /** * design/80 D-1 (persist-winner): the winning resume binding, recorded by {@link CheckpointStore.resolve} * when its CAS wins (pending → resolved) — present ONLY after a `policy_ask` resolve, absent on a freshly * minted pending checkpoint and on non-`policy_ask` resolves. PRESERVED (never cleared) across a * {@link CheckpointStore.reopen} so an `env_failed` re-resume can be validated against it. See * {@link ResolvedOutcome}. */ resolvedOutcome?: ResolvedOutcome; /** * design/80 D-1 (reopen-by-reason): why this checkpoint was last reopened (resolved → pending), recorded * by {@link CheckpointStore.reopen}. Drives re-resume validation (§3 invariant #1): `env_failed` ⇒ the * re-resume must replay the persisted {@link resolvedOutcome} winner (a system retry of the approved * action); `tool_unavailable` ⇒ a fresh operator decision is allowed (P-7 re-decide with the tool * present). Absent on a never-reopened checkpoint (a first resume is unconstrained by reason). */ reopenReason?: ReopenReason; /** * design/80 D-1 (atomicity fix, round-2 BLOCKER): a **monotonic revision counter** bumped by EVERY * {@link CheckpointStore.resolve} (won) and {@link CheckpointStore.reopen}. It is the optimistic-concurrency * key ({@link ResolveExpectation}): a resume reads it at `get()` and `resolve` requires the live `rev` to * still equal it, so any resolve/reopen cycle landing in the get→resolve window loses the CAS (fail-closed). * Unlike the 2-valued `reopenReason`, it cannot ABA back to a prior value. Absent ⇒ legacy `0` (a checkpoint * minted before this field; treated as never mutated). */ rev?: number; /** * design S1d (source-tag persistence, service [198]/[199]): the issuing task's session id, stamped at the * APPROVAL suspend mint so a supervisor inbox can attribute a paused/awaiting checkpoint to the worker that * raised it (the durable analog of {@link import("./tool-policy.js").AskRequest}.sourceTaskId — the durable * suspend path does NOT invoke `onAsk`, so the synchronous source identity is captured here instead). It is * the SAME value the synchronous ask carries: `= sessionId` at the mint. * * **Security invariant (carried verbatim from the 1.113.0 ask-bubbling contract):** for a Runner-created * DELEGATED subagent this is **worker-unforgeable** (a child never sets its own sessionId — the Runner mints * it); a top-level caller MAY continue its own session id via {@link import("./types.js").TaskSpec.sessionId}, * so this is NOT a global "never forgeable" run id — aggregation only aggregates delegated workers, where it * holds. **ECHO-ONLY triage metadata, NEVER a gate input:** no gate / CAS / winner / resume-validation path * reads it (a contract-test pins this). Absent ⇒ no source task id (a non-ask gate, or a legacy checkpoint). */ sourceTaskId?: string; /** * design S1d (source-tag persistence): the issuing task's authenticated end-user {@link * import("./types.js").TaskSpec.principal} (design/62), stamped at the APPROVAL suspend mint so an aggregating * inbox can attribute a paused checkpoint per-user — the durable analog of {@link * import("./tool-policy.js").AskRequest}.principal. Caller-set (never a tool/worker arg), and like * {@link sourceTaskId} it is **ECHO-ONLY triage metadata, NEVER a gate input** (no gate/CAS/winner/resume path * reads it). Absent ⇒ no principal (a non-ask gate, an unauthenticated task, or a legacy checkpoint). */ principal?: string; } /** * design/80 assistant-scheduler seam #1 (channel [148]/[149]): a LIGHTWEIGHT projection of one PENDING * checkpoint, returned in bulk by {@link CheckpointStore.listByScope} so a supervisor scheduler can list a * scope's suspended/awaiting tasks in ONE call (no N+1 `get`s, no full-{@link Checkpoint} payloads). It is a * read-only DISPLAY/triage summary — every field is DERIVED from the persisted {@link Checkpoint}; nothing * here is a new source of truth or a gate input. */ export interface CheckpointSummary { /** The resume capability token ({@link Checkpoint.token}). */ token: CheckpointToken; /** The suspended task / session id ({@link Checkpoint.sessionId}). */ sessionId: string; /** The multi-tenant scope this checkpoint lives in ({@link Checkpoint.scope}) — always equal to the query's. */ scope: string; /** The gate's discriminant ({@link CheckpointGate}.kind): which kind of pause this is. */ gateKind: CheckpointGate["kind"]; /** The deterministic risk tier (1..5) when the gate carries a {@link RiskDescriptor} (the `human` / * `irreversible_ask` approval escalations), derived via {@link riskSeverity} from the descriptor's axes — * the SAME value as `gate.riskDescriptor.severity`, recomputed from the single source helper rather than * re-stored. `undefined` for a gate with no descriptor (resource_limit / needs_review / plan_review / a * legacy human ask minted before D-E). The inbox sorts DESC by this. */ severity?: 1 | 2 | 3 | 4 | 5; /** Cumulative spend debited to this suspend chain ({@link ResourceLedger.spentMicroUsd}) in micro-USD — * present whenever the checkpoint carries a {@link Checkpoint.resourceLedger} (every resource_limit suspend, * and every approval suspend on the D-E-core budget-read path); `undefined` when no ledger is attached. */ spentMicroUsd?: number; /** The awaiting-human SLA / abandonment deadline ({@link Checkpoint.deadline}, epoch ms) when present. */ deadline?: number; /** design S1d (source-tag): the issuing worker's session id ({@link Checkpoint.sourceTaskId}) — projected so * a supervisor inbox attributes each pending checkpoint to its worker in ONE `listByScope` call (no N+1 * `getCheckpoint`). ECHO-ONLY display/triage; absent on a non-ask gate / legacy checkpoint. */ sourceTaskId?: string; /** design S1d (source-tag): the issuing task's end-user principal ({@link Checkpoint.principal}) for per-user * inbox attribution. ECHO-ONLY display/triage; absent when the checkpoint carries no principal. */ principal?: string; /** The pending tool call's id ({@link PendingAction} `tool_approval.toolCallId`) when this is an approval * suspend — projected so the inbox shows WHICH call awaits a decision without an N+1 `getCheckpoint`. * `undefined` for a non-`tool_approval` pendingAction (resource_limit / plan_review / task_done). */ toolCallId?: string; /** The pending tool's name ({@link PendingAction} `tool_approval.toolName`) when this is an approval suspend. * `undefined` for a non-`tool_approval` pendingAction. */ toolName?: string; /** design/99 MF-14 (design-review DoR): the DERIVED content-gate classification — `"content_ask"` when this * `tool_approval` gates the reserved AskUserQuestion tool (a question TO the user, not a side-effecting tool). * A TYPED discriminant so a shell renders the question UI without sniffing `toolName` or parsing the bounded * `toolInput` preview. The full typed questions ride the drilled-in Checkpoint's `pendingAction.args` (kept off * this lightweight `listByScope` projection). Deliberately NOT a new `content_ask` checkpoint gate kind (that * would cross the durable-checkpoint once-only-winner / reaper-deadline / batch invariants — the DoR). */ contentKind?: "content_ask"; /** design S1e (service [204]): when the checkpoint was created ({@link Checkpoint.createdAt}, epoch ms) — * projected so a supervisor inbox can sort/age pending entries (oldest-first triage) in ONE `listByScope` * call, no N+1 `getCheckpoint`. ECHO-ONLY display/triage; always present (a Checkpoint always has a * `createdAt`). */ createdAt?: number; /** * design S1e (service [204]): a **BOUNDED, UNREDACTED** raw preview of the pending tool call's input * ({@link PendingAction} `tool_approval.args` `JSON.stringify`-ed, truncated to {@link * MAX_TOOL_INPUT_PREVIEW_CHARS} chars with a `…` marker when over) — projected so a supervisor inbox shows * WHAT a paused tool call will do without an N+1 `getCheckpoint`. `undefined` for a non-`tool_approval` * pendingAction (resource_limit / plan_review / task_done — no args to preview). * * **Contract (load-bearing):** * - **bounded raw preview** — length-capped to {@link MAX_TOOL_INPUT_PREVIEW_CHARS} so it cannot bloat the * `listByScope` payload, but the chars within the cap are the RAW serialized args (no folding/escaping). * - **redaction is the CONSUMER's responsibility** — core gives the inbox the unredacted bounded raw; the * service/inbox decides what (if anything) to mask before showing a human. (Contrast {@link * RiskDescriptor.summary}, which is the NEUTRALIZED+capped descriptor that DOES ride into a render — * `toolInput` is the raw-data sibling for a consumer that wants the actual args.) * - **same data as the full {@link Checkpoint}** — it is a projection of `pendingAction.args`, which the * caller could already read via `get`; this adds NO new exposure, it only saves the round-trip. * - **ECHO-ONLY, like {@link sourceTaskId}** — NO gate / CAS / resume / {@link winnerFromOutcome} path reads * it (a contract test pins this); it is pure display/triage metadata, never a control input. */ toolInput?: string; } /** * design/80 assistant-scheduler seam #1: project a single PENDING {@link Checkpoint} to its lightweight * {@link CheckpointSummary}. Shared by every {@link CheckpointStore} impl so the projection is IDENTICAL * across the in-memory, file, and durable backends (the anti-drift guard) — `severity` always derives from the * gate's {@link RiskDescriptor} via the single {@link riskSeverity} helper, `spentMicroUsd` always reads * {@link ResourceLedger.spentMicroUsd}. Pure; reads no clock/random. */ export declare function summarizeCheckpoint(cp: Checkpoint): CheckpointSummary; /** A typed checkpoint-store error so callers branch on `code` (mirrors `SessionError`). */ export declare class CheckpointError extends Error { readonly code: "checkpoint.already_exists" | "checkpoint.already_resolved" | "checkpoint.not_found" /** `runner.resume` was handed an {@link ResumeOutcome} whose `gate` arm does not match the * persisted {@link CheckpointGate} `kind` (council #3 — type confusion guard), or a gate v1 * resume does not serve (`task_done`). */ | "checkpoint.gate_mismatch" /** `runner.resume` was handed an outcome whose payload is unsafe to apply — e.g. a deny `reason` * carrying a `` tag that would escape the model-facing wrapper (round-2 #1). */ | "checkpoint.invalid_outcome" /** The checkpoint's {@link Checkpoint.version} is newer than this worker supports * (`> MAX_SUPPORTED_CHECKPOINT_VERSION`), or it carries a remote `workspaceHandle` but no * `executionEnvFactory` is wired to rebuild the env — either way this worker cannot safely resume it. * Rejected pre-CAS so the checkpoint stays `pending` and a capable worker can still resume it * (design/49 §2/§3, code-ready council round-2). */ | "checkpoint.unsupported_version" /** design/80 D-1 (reopen-by-reason): a re-resume of an `env_failed`-reopened checkpoint supplied an * outcome that does NOT equal the persisted {@link ResolvedOutcome} winner. An `env_failed` reopen is * a SYSTEM RETRY of the already-approved action (the infra failed, not the decision) — it must replay * the exact winning binding, never a new vote. Rejected pre-CAS, fail-closed, so the checkpoint stays * `pending` for the correctly-replayed retry. (A `tool_unavailable` reopen — P-7 — is exempt: it lets * a human re-decide with the tool present, so a fresh decision is permitted there.) */ | "checkpoint.reopen_revote" /** design/80 D-1 (atomicity fix): the resume validated its decision-action / reopen-by-reason guards * against a `get()` snapshot, but a concurrent {@link CheckpointStore.resolve}/{@link CheckpointStore.reopen} * cycle advanced the monotonic {@link Checkpoint.rev} in the get→resolve window, so the * {@link CheckpointStore.resolve} CAS's optimistic-concurrency check lost (the validation is stale). The * row is still `pending` — fail-closed; the caller must re-`get` and re-validate against the CURRENT state * (a fresh resume), never blindly retry. */ | "checkpoint.reopened_concurrently" /** design/80 D-A: {@link CheckpointStore.setPendingSteer} was handed `text` containing a * `` close tag — it would escape the model-facing `` wrapper a * trusted steer rides on resume. Rejected fail-closed so a dirty steer NEVER enters {@link CheckpointState} * (§3 inv #2). Mirrors the trusted-steer reject at runtask.ts (`steering.invalid_content`). */ | "steering.invalid_content"; constructor(code: "checkpoint.already_exists" | "checkpoint.already_resolved" | "checkpoint.not_found" /** `runner.resume` was handed an {@link ResumeOutcome} whose `gate` arm does not match the * persisted {@link CheckpointGate} `kind` (council #3 — type confusion guard), or a gate v1 * resume does not serve (`task_done`). */ | "checkpoint.gate_mismatch" /** `runner.resume` was handed an outcome whose payload is unsafe to apply — e.g. a deny `reason` * carrying a `` tag that would escape the model-facing wrapper (round-2 #1). */ | "checkpoint.invalid_outcome" /** The checkpoint's {@link Checkpoint.version} is newer than this worker supports * (`> MAX_SUPPORTED_CHECKPOINT_VERSION`), or it carries a remote `workspaceHandle` but no * `executionEnvFactory` is wired to rebuild the env — either way this worker cannot safely resume it. * Rejected pre-CAS so the checkpoint stays `pending` and a capable worker can still resume it * (design/49 §2/§3, code-ready council round-2). */ | "checkpoint.unsupported_version" /** design/80 D-1 (reopen-by-reason): a re-resume of an `env_failed`-reopened checkpoint supplied an * outcome that does NOT equal the persisted {@link ResolvedOutcome} winner. An `env_failed` reopen is * a SYSTEM RETRY of the already-approved action (the infra failed, not the decision) — it must replay * the exact winning binding, never a new vote. Rejected pre-CAS, fail-closed, so the checkpoint stays * `pending` for the correctly-replayed retry. (A `tool_unavailable` reopen — P-7 — is exempt: it lets * a human re-decide with the tool present, so a fresh decision is permitted there.) */ | "checkpoint.reopen_revote" /** design/80 D-1 (atomicity fix): the resume validated its decision-action / reopen-by-reason guards * against a `get()` snapshot, but a concurrent {@link CheckpointStore.resolve}/{@link CheckpointStore.reopen} * cycle advanced the monotonic {@link Checkpoint.rev} in the get→resolve window, so the * {@link CheckpointStore.resolve} CAS's optimistic-concurrency check lost (the validation is stale). The * row is still `pending` — fail-closed; the caller must re-`get` and re-validate against the CURRENT state * (a fresh resume), never blindly retry. */ | "checkpoint.reopened_concurrently" /** design/80 D-A: {@link CheckpointStore.setPendingSteer} was handed `text` containing a * `` close tag — it would escape the model-facing `` wrapper a * trusted steer rides on resume. Rejected fail-closed so a dirty steer NEVER enters {@link CheckpointState} * (§3 inv #2). Mirrors the trusted-steer reject at runtask.ts (`steering.invalid_content`). */ | "steering.invalid_content", message: string); } /** * The pluggable durable seam (design/45 §2.1). Symmetric with `ToolResultStore`/`MemoryStore`: * create-once `put`, `get`, an **atomic CAS `resolve`** (the once-only foundation), and a `reap` for TTL * expiry. A durable backend (TiDB) makes resume cross-replica; the default in-memory impl is single * instance / tests only. * * **Load-bearing contract (§2.1, service [4]):** `resolve` is the once-only gate. The store guarantees * *exactly one* `resolve(token, ...)` wins the CAS (pending → resolved); the **runner** must treat * "won the CAS" as the *sole* trigger to execute the pending action — that is what makes a pending tool * call run exactly once across retries / double approvals / multiple replicas. * * **design/80 §D-D expiry semantic contract (gate.kind-aware reap + `terminalAt` backstop):** the * reaper-LOOP that calls {@link reap}/{@link expire} is the DEPLOYMENT's (its store impl + loop — OWNED * service-side, channel [135]/[136]); core's D-D job is ONLY to keep writing the durable FIELDS the loop * reads ({@link Checkpoint.deadline} for an approval gate + the persisted {@link Checkpoint.gate}.kind) and * to DOCUMENT the contract here so ANY reaper consumer (the service's TiDB loop, or a non-service Pg * deployment) implements it IDENTICALLY: * - **FACET A — APPROVAL gates** (`gate.kind` ∈ {`human`, `irreversible_ask`}): the loop's FAST PATH at * `deadline` is an SLA **resolve-deny** (`resolve(token, {gate:"policy_ask", decision:"deny"}, …)` so the * suspended run ends cleanly), with a LATER `terminalAt` **abandonment backstop** the loop ALSO honors * (crash-safe if the SLA timer never fired — then {@link reap}/{@link expire} flips it `expired`). * - **FACET B — UNATTENDED-TTL gates** (`gate.kind` ∈ {`resource_limit`, `needs_review`, `plan_review`} — the * resource slice + the review-pause family): `deadline` is an abandonment-TTL → {@link expire}/{@link reap} * ONLY, **NEVER** a resolve-deny (a resolve-deny on these kinds hits NO `gateMatch` arm → `gate_mismatch`; * their resume outcome is `resource_limit`/`dry_run_review`/`plan_review` respectively, not `policy_ask`). * NO core-minted `terminalAt`, NO core reaper, NO change to {@link reap} logic — minting `terminalAt` and * running the gate.kind-aware loop are the deployment's, by design. */ export interface CheckpointStore { /** Create-once. Throws {@link CheckpointError} `already_exists` on a token collision (never a silent * overwrite — a reused token would clobber a live suspension). */ put(token: CheckpointToken, cp: Checkpoint): Promise; get(token: CheckpointToken): Promise; /** * Atomic CAS: `UPDATE … SET status='resolved' WHERE token=? AND scope=? AND status='pending'`. * Returns `true` for the single winner (was pending → now resolved), `false` if already resolved/ * expired (`AlreadyResolved` — the caller treats it as a no-op, never re-executes). `scope` is in the * WHERE for multi-tenant isolation (a wrong-scope resolve must not win). * * `outcome` is supplied so a **durable** backend persists it atomically with the status flip (design/45 * v2 resumable-resume hook). **design/80 D-1 (persist-winner):** when the CAS wins on a `policy_ask` * outcome, the store ALSO records the winning binding (`{boundCallId, decision, updatedInput?}`) as * {@link Checkpoint.resolvedOutcome} on the row — so a later {@link reopen} → re-resume can be validated * against the approved decision (an `env_failed` reopen must replay the same winner; design/80 §3 inv #1). * Non-`policy_ask` outcomes (`resource_limit` / `dry_run_review` / `task_done`) bind to no action and * record no winner. (The crash-recovery semantics of *acting* on a won CAS are runner contract — see * {@link Runner.resume}; the store only owns the atomic once-only flip + the durable winner.) */ resolve(token: CheckpointToken, scope: string, outcome: ResumeOutcome, expect?: ResolveExpectation): Promise; /** * Inverse of {@link resolve}: CAS `resolved → pending` (`UPDATE … SET status='pending' WHERE token=? * AND scope=? AND status='resolved'`). Returns the CAS winner (`true` = was resolved → now pending again). * * **Compensation for a post-CAS env-restore failure (design/45/49).** `Runner.resumeStream` wins the * `resolve` CAS *before* the workspace `resumeVM` runs; if that restore then fails (`resume.env_failed`) * the pending action never executed yet the checkpoint is consumed — without this the suspended work is * lost to a forced "re-initiate" (fine for a short approval, ruinous for a long autonomous task). * Reopening lets a retry re-resume the SAME work. Optional: a store that omits it keeps the prior (lossy) * behavior, and the runner degrades gracefully. Safe by the `status='resolved'` guard — only the run that * consumed the checkpoint (and then failed to restore) can reopen it, and only while it is still `resolved`. * * **design/80 D-1 (reopen-by-reason):** the `reason` distinguishes the two reopen triggers (both shipped at * runtask.ts) so re-resume can validate per reason (§3 inv #1): `env_failed` (the action is still valid, * only infra failed → re-resume MUST replay the persisted {@link Checkpoint.resolvedOutcome} winner) vs * `tool_unavailable` (P-7: the approved tool vanished, the action may now be invalid → a human re-decides, * so a FRESH decision is allowed). The store RECORDS the reason on the row as {@link Checkpoint.reopenReason} * and PRESERVES the persisted winner across the reopen (never clears it). */ reopen?(token: CheckpointToken, scope: string, reason: ReopenReason): Promise; /** * design/80 D-A: park a durable mid-task STEER on a DURABLY-SUSPENDED task's checkpoint. CAS-style update * `SET state.pendingSteer=? WHERE token=? AND scope=? AND status='pending'`: returns `true` iff the * checkpoint is still `pending` (a resolved/expired checkpoint can't be steered → `false`, a no-op). Scope * is in the WHERE for multi-tenant isolation, exactly like {@link resolve}/{@link expire}. Idempotent-safe: * a later `setPendingSteer` overwrites the parked steer (last-writer-wins) on a still-pending checkpoint. * * **Trust is FROZEN here** — `steer.trusted` is whatever the SERVICE computed from the verified principal * at this call (an operator-role check, NOT a client header — the service's job); the core stores it * VERBATIM and NEVER recomputes it on resume (§3 inv #1). * * **Validation (load-bearing, §3 inv #2):** `steer.text` containing a `` close tag is * REJECTED with a typed {@link CheckpointError} `steering.invalid_content` (mirroring runtask.ts's trusted- * steer reject) — a dirty steer NEVER enters {@link CheckpointState}, so the persisted state stays clean * regardless of `trusted`. (The untrusted RESUME-injection path ALSO sanitizes the text as untrusted data; * this persist-time reject is the belt that keeps a forged close tag out of the durable record on BOTH * paths.) NOT an approval channel: this only writes `state.pendingSteer`, never touches status / the * resolve path / any decision (§3 inv #4). */ setPendingSteer(token: CheckpointToken, scope: string, steer: { text: string; trusted: boolean; }): Promise; /** * CAS-expire a **single** `pending` checkpoint by token: `UPDATE … SET status='expired' WHERE token=? * AND scope=? AND status='pending'`. Returns the CAS winner (`true` = was pending → now expired; `false` * = already resolved/expired). The same UPDATE as {@link reap} but keyed by **token**, not by deadline. * * **Load-bearing contract (design/51 §2/§4):** `expire` and {@link resolve} race the **same `pending` * row**, so the store serializes them — exactly one wins. This is what makes {@link TaskStream.destroy} * a correct *fence-then-reap*: `destroy` calls `expire` FIRST to fence any concurrent `resume` (which * goes through `resolve`); only the `expire` winner then destroys the paused env, so the same checkpoint * is **never both reaped and resumed**. `scope` is in the WHERE for multi-tenant isolation, like * `resolve`/`reap`. */ expire(token: CheckpointToken, scope: string): Promise; /** * CAS-expire `pending` checkpoints in `scope` whose `deadline` has passed (`deadline <= cutoff`): * `pending → expired`. Returns the count expired (for metrics). Idempotent across replicas (DB * serializes; only the first wins each row) — no leader election needed (§6). * * **Does NOT unpin the sessions of the checkpoints it expires (council finding #2).** A suspend pinned * its session against idle eviction; reaping the checkpoint here leaves that pin in place. The reaper * that calls `reap` owns releasing the pin. For a **durable** session store this is a no-op anyway * (`pin`/`unpin` are no-ops; it never idle-GCs), so reap-without-unpin is harmless — the only store with * a real pin is the in-memory `TtlSessionStore` (single-instance / tests), where an un-released pin * keeps an abandoned suspended session in the cache until process exit. v1 leaves the runner without an * auto-reaper (the service owns the TTL worker); a reaper over the in-memory store should track and * `unpin` the expired sessions itself if that leak matters for its deployment. */ reap(scope: string, cutoff: number): Promise; /** * design/80 assistant-scheduler seam #1 (channel [148]/[149]): list a lightweight {@link CheckpointSummary} * for EVERY **pending** checkpoint in `scope` (the suspended / awaiting-human tasks — NOT resolved/expired) in * ONE call, so a supervisor scheduler can enumerate a scope's open suspensions without N+1 `get`s or hauling * full {@link Checkpoint} payloads. Read-only — it never mutates a row, takes no CAS, and has ZERO effect on * the once-only resolve. The PENDING predicate is byte-for-byte the {@link resolve} CAS's (`scope === scope && * status === 'pending'`), so the list reflects exactly the rows that are still resumable. Order is unspecified * (the inbox sorts by `severity`/`deadline` itself). An empty scope returns `[]`. * * **OPTIONAL** for backward-compat with external {@link CheckpointStore} impls (a caller probes * `store.listByScope?.(scope) ?? []`); ALL THREE first-party impls (in-memory, file, Pg) provide it. */ listByScope?(scope: string): Promise; } /** * design/80 D-1 (persist-winner): derive the durable {@link ResolvedOutcome} winner from a resume * `outcome`, or `undefined` when the outcome binds to no pending action. Only a `policy_ask` resume names * a specific tool call (`boundCallId`) + decision worth recording; `resource_limit` / `dry_run_review` / * `task_done` have nothing to bind, so they record no winner. Shared by every {@link CheckpointStore} impl * so the persisted winner is identical across the in-memory and durable backends. */ export declare function winnerFromOutcome(outcome: ResumeOutcome): ResolvedOutcome | undefined; /** * design/80 D-A: the persist-time validation every {@link CheckpointStore.setPendingSteer} impl runs so the * reject is IDENTICAL across the in-memory and durable backends. A `steer.text` carrying a `` * close tag would escape the model-facing `` wrapper a trusted steer rides on resume — reject * fail-closed (`steering.invalid_content`) so a dirty steer NEVER enters {@link CheckpointState} (§3 inv #2). * Applied for BOTH `trusted` values: the durable record must be clean on either path (the untrusted resume * path ALSO sanitizes as untrusted data, but the close tag must never reach the persisted state at all). * Returns a frozen-trust copy (`{ text, trusted }` only) so no extra caller field leaks into the row. */ export declare function validatePendingSteer(steer: { text: string; trusted: boolean; }): { text: string; trusted: boolean; }; /** Fault-injection mode for {@link InMemoryCheckpointStore.testInjectFault} (council #5). One-shot. */ export type CheckpointFaultMode = /** `resolve` commits the CAS (status → resolved) then throws — simulates a crash *after* the commit * but before the caller is acked, so a retry must see `resolved` and NOT re-execute (idempotency). */ "resolve-after-commit" /** `resolve` throws *before* the CAS — simulates a crash before the commit; the row stays `pending` * so a retry can still win it. */ | "resolve-before-commit"; /** * Default in-process {@link CheckpointStore}. Single-instance / tests only — it does NOT survive a * restart or span replicas, so it cannot deliver the cross-process guarantee a durable backend does. * Single-threaded JS already serializes `resolve`, so the CAS is trivially atomic here; * {@link testInjectFault} simulates the crash-recovery races a real backend must survive. */ export declare class InMemoryCheckpointStore implements CheckpointStore { private cps; private fault; put(token: CheckpointToken, cp: Checkpoint): Promise; get(token: CheckpointToken): Promise; resolve(token: CheckpointToken, scope: string, outcome: ResumeOutcome, expect?: ResolveExpectation): Promise; reopen(token: CheckpointToken, scope: string, reason: ReopenReason): Promise; setPendingSteer(token: CheckpointToken, scope: string, steer: { text: string; trusted: boolean; }): Promise; expire(token: CheckpointToken, scope: string): Promise; reap(scope: string, cutoff: number): Promise; listByScope(scope: string): Promise; /** Arm a one-shot fault on the next `resolve` (council #5: makes crash-recovery unit-testable). */ testInjectFault(mode: CheckpointFaultMode | null): void; /** Test/inspection helper: number of stored checkpoints. */ get size(): number; } //# sourceMappingURL=checkpoint-store.d.ts.map