import type { Model } from "../../internal/llm.js"; import { type Checkpoint, type CheckpointToken, type ReopenReason, type ResumeOutcome } from "../checkpoint-store.js"; import type { RunInternals } from "./prepare-task.js"; import { type TaskOutcome } from "../task-outcome.js"; import type { SessionStore } from "../session.js"; import type { AgentDefinition, RunnerDeps, TaskEvent, TaskResult, TaskSpec, TaskStream } from "../types.js"; /** * Default per-task turn cap (F9) — a HIGH absolute safety net, applied when the caller set neither `maxTurns` * nor `timeoutSec`. This is a CONSERVATIVE SAFETY NET — its job is to catch a model wedged in a tool-call * loop early, before it burns budget pinning a worker. It is NOT the knob for "let long tasks run long": * a genuinely long autonomous run sets its OWN explicit bound (the leader path sets all three on the worker * spec — 10000 turns / $100 / 24h — see ai-agent-service wire.ts `leaderResourceConfig`). * * History: briefly raised 100 → 10000 on 2026-06-14 to stop leader workers dying at 100, then REVERTED to * 100 on 2026-06-15. The raise was the wrong lever: (a) the leader worker already sets maxTurns explicitly * via wire.ts, so the core default never gated the real path; (b) a 10000 default defeats the safety-net * purpose; (c) it broke ~23 teacher tests whose unbounded mock brains rely on this cap to terminate * (O(n²) context rebuild × starved timers → hang). The real fix for "hitting a cap loses progress" is * design/74 resource-suspend (turn `failed` into a resumable `suspended` third state) — NOT a bigger number. * * 2026-07-10 (clay): 100 → 500. The 2026-06-15 reasoning stands — this is still a runaway safety net, not * the long-run knob — but 100 proved too conservative a net for long-horizon engineering tasks (the * winning-avg-corewars deadtarget verdict measured a 120-turn task-side cap binding ~21 minutes BEFORE the * wall clock; a 100 default is tighter than real workloads). 500 still catches a wedged tool-call loop well * before a 10000-turn burn, and hosts (server / 飞轮) may set their own: explicit `limits.maxTurns` always * wins, and `maxTurns: 0` means UNBOUNDED (semantics unchanged). */ export declare const DEFAULT_MAX_TURNS = 500; /** * Config re-supplied to {@link Runner.resume} (design/45). A suspended task's tools / model / policy / * hooks cannot be reconstructed from a checkpoint token (the session stores neither tool implementations * nor the hand band), so the caller's trusted control plane re-supplies the same {@link TaskSpec} it ran * with — minus the conversation bits: `sessionId` comes from the checkpoint and `objective` is replaced by * an internally-generated continuation, so both are omitted. */ export type ResumeTaskConfig = Omit; /** design/45 resume plan threaded from {@link Runner.resume} into the shared run loop. */ interface ResumeRun { cp: Checkpoint; /** Validated against `cp.gate.kind` at the resume entry: human/irreversible_ask→`policy_ask`, * resource_limit→`resource_limit` (design/74), needs_review→`dry_run_review` (design/76 §2.5), * plan_review→`plan_review` (design/80 D-B). The gate-match guard in `resumeStream` enforces the * correlation. */ outcome: Extract; /** Compensation hook (design/45/49): called iff the resumed run fails with `resume.env_failed` (post-CAS * workspace `resumeVM` failed) OR `resume.tool_unavailable` (P-7: the approved tool vanished) — in both * the CAS already consumed the checkpoint but the pending action never ran. `resumeStream` supplies a * closure that reopens the checkpoint (`resolved → pending`) so a retry re-resumes the SAME suspended work * instead of losing it to a forced "re-initiate". design/80 D-1 (reopen-by-reason): the `reason` is * recorded on the reopened row so the next re-resume validates per reason — an `env_failed` reopen must * replay the persisted winner (a system retry of the approved action), while a `tool_unavailable` reopen * lets a human re-decide with the tool present (a fresh decision is allowed — preserves P-7). */ onEnvRestoreFailed?: (reason: ReopenReason) => Promise; } /** * A stateless task runner. Holds shared deps (the external brain, model catalog) and an * in-memory session store so that passing a `sessionId` continues a prior conversation. */ export declare class Runner { private deps; readonly sessions: SessionStore; /** Per-sessionId serialization so two tasks never mutate one session concurrently. */ private sessionLocks; /** service [403](a) — roots whose rewind-files snapshot hit `too_large`, with the refusal time. * A 20G workspace otherwise pays a full bounded tree-walk (stat tens of thousands of files) EVERY * task just to re-discover the same refusal; once too_large, skip further snapshots for that root * (announced once via onError). E19 semantics unchanged — rewind for that root was already * impossible. codex 131 审 C: TTL'd (30min), not permanent — a long-lived Runner over a tree the * user later SHRINKS re-probes instead of being locked out until restart. */ private snapshotTooLargeRoots; /** 飞轮 [492]② — task notifications born BETWEEN turns (run torn down / harness already idle), parked * per session and drained into the session's next run at its first boundary. Runner-lived (outlives any * single run, like the registry handles that produce into it); bounded + drop-disclosing, see * {@link PendingSessionNotifications}. Not checkpointed — the producing handles are process-local. */ private readonly pendingSessionNotifications; constructor(deps: RunnerDeps); /** design/73 §1 v1 — fire `RunnerDeps.onTaskOutcome` through the single swallow-guarded chokepoint * ({@link emitTaskOutcome}: mechanical-tier only, throwing sink never breaks the caller). Public so * thin compositions OVER the Runner (`runGoal`) can emit at their terminal state without reaching * into private deps. */ emitTaskOutcome(outcome: TaskOutcome): void; /** design/141 件A — the deployment agent catalog (RunnerDeps.agents/builtinAgents), exposed READ-ONLY * so the Agent-tool lane (`createSubagentTool`) defaults to the SAME registry the workflow lane * consumes (single source; the types.ts "pass the same array" follow-on made structural). The array * is a fresh copy — a caller mutating it never rewrites this Runner's own deps; the definitions * themselves are the shared objects (frozen by convention, same as gateBaseline's posture). */ get agentCatalog(): { agents?: AgentDefinition[]; builtinAgents?: boolean; models?: Record; }; /** design/125 (codex 实现审 B1) — the deployment-level gating baseline, exposed READ-ONLY so thin * assemblers over the Runner (`runSpec`) can COMPOSE with it. The task-level engine semantic is a * WHOLE-SLOT override (`spec.toolPolicy ?? deps.toolPolicy`, same for hooks — prepare-task), so any * assembler that sets `spec.toolPolicy`/`spec.hooks` without folding these in silently shadows the * deployment baseline. Getter only — nothing here is writable from outside. */ get gateBaseline(): { toolPolicy?: RunnerDeps["toolPolicy"]; hooks?: RunnerDeps["hooks"]; }; /** Acquire the lock for a sessionId; returns a release fn. New (undefined) sessions need no lock. */ private acquireSessionLock; /** Run a task and stream live events; await `.result()` for the final TaskResult. `resume` (internal) * drives a design/45 durable resume through the same loop instead of a fresh objective prompt. * `internals` (internal, design/78 Slice-1) is a TRUSTED run-scoped channel for live per-task state the * Runner cannot see from `spec` (today: a `runRepairLoop` attempt's live `repairBundle`) — NOT a * `TaskSpec` field; undefined on the public path. * **Eager:** the task starts executing the moment this stream is constructed (fire-and-forget), NOT lazily * on first iteration — you do NOT need to pull events for the run (and its `finish()`/env teardown) to * happen. Iterating just observes events (the buffer is backpressure-free); `.result()` awaits completion. */ runTaskStream(spec: TaskSpec, resume?: ResumeRun, internals?: RunInternals): TaskStream; private runLocked; /** Post-task memory consolidation (design/41). Never throws — failures route to `onError(phase:"memory")`. */ private consolidateMemory; /** * design/100 §E12 — the post-completion prompt-suggestion pass. Never throws (failures route to * `onError(phase:"suggestions")` and resolve to `[]`), so it is fire-and-forget safe and `suggestions()` * never rejects. Also fills the budget-excluded `stats.suggestions` line, mirroring `consolidateMemory`. */ private suggestNextPrompts; /** * design/101 §E19 — capture a working-tree snapshot of the just-finished turn, keyed by the leaf * `SessionTreeEntry.id`. Best-effort + never throws (errors route to `onError(phase:"rewind")`). Runs against * ANY ExecutionEnv (local or remote) when a `fileSnapshotStore` is wired (GATE-SPLIT, service [263]) — the * snapshot is portable (env FileSystem ops). A deployment wanting VM-snapshot instead wires a VM-backed store. */ private snapshotTurn; /** Run a task to completion and return a machine-readable result. */ runTask(spec: TaskSpec, internals?: RunInternals): Promise; /** * Resume a task suspended at a durable approval gate (design/45 F4). The token came back as * `TaskResult.checkpointToken` from a `status:"suspended"` run; `outcome` carries the human/external * decision (`allow`/`deny`, with an optional `updatedInput` arg rewrite or `reason`); `taskConfig` * **re-supplies** the same tools / model / policy the task ran with (a token cannot reconstruct tool * implementations or the hand band — the session stores neither). * * Flow (§2.1): `get` the checkpoint → validate the outcome arm matches the gate (council #3) → **atomic * CAS `resolve`**. *Winning the CAS is the sole trigger to execute the pending action* — so a double / * concurrent resume of the same token loses the CAS and is rejected (`checkpoint.already_resolved`), * never re-running the side-effecting tool. The winner rewinds the session to the suspension point, * resolves the gated call (execute on `allow`, inject a denial on `deny`), closes the rest of the * suspended batch as deferred, and re-enters the run loop with a continuation — returning a normal * {@link TaskResult} (which may itself be `suspended` again with a fresh token). * * v1 serves only the `human`/`policy_ask` gate (F4). A `task_done` checkpoint (1C Path A) is not * resumable here — the caller orchestrates that one and reads its handle directly. * * **🔒 Authorization boundary (design/80 D-2 / [122] invariant #1) — token-as-auth, scope is store-level * isolation, NOT caller authorization.** `resume` is TOKEN-AS-AUTH: whoever presents a valid, still-`pending` * checkpoint token resolves it. The scope passed to the store CAS is the checkpoint's OWN `cp.scope` (read * off the row), so the store's scope-WHERE is store-level multi-tenant DATA isolation (one tenant's reaper / * mis-scoped resolve can't touch another's row) — it does **not** verify that THIS CALLER is authorized for * that scope. Authorizing the operator's principal against the checkpoint (the "tenant-B holding tenant-A's * token must not resolve it" property) is the **caller's** responsibility: the BFF / supervisor service mints * the principal, scopes the approval inbox to it, and only calls `resume` for a checkpoint that principal * owns. The token's secrecy + single-use HMAC binding (D-G) is what keeps it from reaching the wrong tenant. * A direct (non-BFF) client is BLOCKED until D-G's cryptographic principal binding — never trust a * client-supplied principal/scope here. * * @throws {@link CheckpointError} `not_found` (no store / unknown token), `gate_mismatch` (outcome arm * ≠ gate, or an unsupported gate), `invalid_outcome` (decision-action binding failed — boundCallId/ * boundInputHash mismatch, or a deny reason with a `` tag), `reopen_revote` (an * env_failed re-resume supplied a decision ≠ the persisted winner), `reopened_concurrently` (the * optimistic-concurrency `rev` changed under a concurrent resolve/reopen — re-resume against current * state), `unsupported_version` (checkpoint newer than this worker / remote handle with no factory), * `already_resolved` (lost the CAS — idempotent no-op). */ resume(token: CheckpointToken, outcome: ResumeOutcome, taskConfig: ResumeTaskConfig): Promise; /** * Streaming form of {@link resume}: runs the identical pre-CAS guards + atomic CAS, then returns the live * {@link TaskStream} of the resumed run (its events + `result()`) instead of draining it to a final * {@link TaskResult}. Use this when the resumed segment's tool/turn events must reach an event sink for * observability parity with the original `runInBackground` stream (service [15]); `resume()` is the * convenience wrapper that drains it. The guards + CAS run FIRST, so this rejects with the same * {@link CheckpointError}s as `resume` *before* any stream is returned (winning the CAS is still the sole * trigger to execute the pending action — a lost CAS rejects, never returns a re-running stream). * * **Eager (like {@link runTaskStream}):** once this resolves, the resumed run is already executing — the * pending action runs exactly once and the run-loop tail's `teardownOwnedEnv` (service [325]) tears down the * rebuilt remote env even if you never iterate the stream. So abandoning the returned stream is safe (no leak, * no hang); it only means you * don't observe the resumed segment's events. */ resumeStream(token: CheckpointToken, outcome: ResumeOutcome, taskConfig: ResumeTaskConfig, /** service [371]①: the TRUSTED run-internals seam, symmetric with {@link runTaskStream} — a resume leg * otherwise has no `onForwardEvent`席位, so a subagent's task_progress ticks (child-isolated stream, * only exit = the forward sink) were unreachable on resume. Deployment-owned, never a TaskSpec field. */ internals?: RunInternals): Promise; /** * design/45 resume step: with the harness idle and the branch rewound to the suspension leaf, resolve * the gated tool call and close the rest of the suspended batch, all by appending `toolResult`s to the * session (the next `harness.prompt(continuation)` replays them as context). * - `allow` → execute the pending tool ONCE (re-validating `updatedInput`/captured post-hook args), * append its real result. This deliberately bypasses the tool gate (the human already adjudicated) * and the PostToolUse hook (documented v1 gap — rare, acceptable). * - `deny` → append a model-readable denial result instead. * - the remaining batch siblings (#k+1..N) → deferred-reissue results (v1 doesn't blind-run them). */ private applyResumeDecision; /** Resolve the single gated call of a resumed batch (design/45): execute it once on `allow`, or inject * a model-readable denial on `deny`. Appends exactly one `toolResult` for `pendingAction.toolCallId`. * `emit` streams `tool_start`/`tool_end` for the resolved call so a `resumeStream` observer sees the * approved tool actually execute (API#2 observability parity — service [15]/[37]; the gated call never * emitted execution events in the original run, it suspended at the gate before running). */ private resolvePendingCall; /** * design/84 Seam C — the cost-optimization compaction options threaded into BOTH `maybeCompact` call * sites (within-task turn boundary + `finish()`). All three fields come from `RunnerDeps` (a trusted * FUNCTION seam — never `TaskSpec`, which is serializable/durable-resumable/untrusted-caller). The * Runner OWNS the consecutive-reuse counter (`prepared.compactionReuseRef`) so the * `maxConsecutiveProviderReuse` drift guard spans the whole task across both sites: it FEEDS the current * count in as `consecutiveProviderReuse`, and {@link recordCompactionReuse} updates it from the result. * Returns `undefined` when no provider is wired (so the call site spreads nothing → byte-identical to * the pre-design/84 behavior). */ private seamCCompactionOptions; /** * design/86 §3 缺口 A — DYNAMIC re-recall. Fired (best-effort) AFTER a real within-task compaction when the * seam is wired (`prepared.dynamicRecall` present ⇒ deps.dynamicRecall.enabled + memory + selector + a * selective-capable store). Re-selects more-relevant memory for the CURRENT conversation and injects any NEW * notes as a TAIL attachment user message via `harness.steer` — the next request's tail, NEVER the stable * system prefix (touching the prefix would break the 90–98% prefix cache). * * Reuses the EXACT initial-recall pipeline (`selectAndComposeMemory`/`Layered` via {@link runDynamicRecall}), * de-duping against the run's cumulative surfaced set (seeded from the initial recall) so an already-shown * note is never re-injected. Security (design/86 §3 ⑤): the bodies come back through the same read API + * compose+fence+caveat path the initial recall uses, over a store whose every note already passed the * WRITE-side secret gate — this adds no new inlet, so it cannot leak anything the initial recall couldn't. * * NEVER throws: a failure (selector blow-up, steer race) is swallowed (re-recall is an optimization; the * stable prefix still holds the initial memory). The selector's own timeout/degrade is handled inside * `selectAndComposeMemory`; a degrade injects nothing (no inject-all fallback mid-task — see runDynamicRecall). */ private maybeDynamicRecall; /** design/134 §3.2 — resolve the pre/postCompact lifecycle callbacks (whole-slot `spec.hooks ?? * deps.hooks`, same resolution as the stop hook) and wrap each in a SWALLOW+TRACE shell before * threading them into maybeCompact. The wrapper owns the observability half of the R3 MED contract * (maybeCompact swallows defensively too, but has no sink): a throwing callback is reported via * `onError(phase:"hook")` and treated as absent; a `block` returned under a "forced" trigger is * reported as ignored (maybeCompact enforces the ignore — blocking a compaction the provider/trim * layer already demanded would kill the run). */ private compactionHookOptions; /** design/84 Seam C — fold a finished compaction's `reused` flag into the run-scoped consecutive-reuse * counter: a reused (provider) summary increments it, a real (LLM) summary resets it to 0. A no-op * compaction (`compacted:false`) leaves the counter untouched. No-op when no provider is wired. */ private recordCompactionReuse; private finish; /** * design/48 remote seam + service [325]: tear down a per-task env minted by `executionEnvFactory` (e.g. a * remote container, or a `withWorktreeIsolation` worktree) — this task owned its lifetime. Best-effort, like * `mcp.dispose`: a `destroy()` failure must not break the run. Only `ownedEnv` (factory-produced) is destroyed; * a caller-owned static `deps.executionEnv` outlives the task and is left untouched. The service control plane's * reaper is the backstop for an env orphaned by a rare pre-teardown throw (same posture as `mcp`). * * Runs from the run-loop tail AFTER every env-reader (finish()'s compaction `attachWorkingFiles` + * `snapshotTurn`'s rewind-files capture), so the working tree is still readable when they run. This ORDER is the * fix for service [325]: the old in-`finish()` destroy ran BEFORE `snapshotTurn`, so a factory-minted * remote/worktree lane snapshotted an already-destroyed env (plain host/static lanes were false-green). * * design/49 v1.5 / design/76 §2.5: a SUSPENDED (`suspendRef.token`) or `needs_review` (`reviewRef.token`) pause * `suspendVM`-paused the env and persisted its `workspaceHandle` into the checkpoint — destroying it would * discard the paused VM and make resume fail to restore. Skip teardown; `resume()` rebuilds + `resumeVM`s it. A * cancelled pause is reaped via `TaskStream.destroy()` (design/51); an abandoned one is the service container * reaper's backstop. The gate here MIRRORS the tail's reap-stash (same durable fact * `suspendRef.token===undefined && reviewRef.token===undefined`), so exactly one path owns the env's fate. */ private teardownOwnedEnv; } /** Convenience: one-shot run with explicit deps (creates a throwaway Runner). */ export declare function runTask(spec: TaskSpec, deps: RunnerDeps): Promise; export type { TaskEvent, TaskStream }; //# sourceMappingURL=runtask.d.ts.map