/** Kill policy applied to IN-FLIGHT nodes when a campaign-level budget exhausts. */ export type CampaignKillPolicy = 'kill-running' | 'abandon-running'; /** * The resolved budget policy a campaign runs under. All ceilings are explicit values — * resolution (`resolveBudgetPolicy`) fails closed on malformed input rather than silently * falling back, because a typo'd budget must never mean "unbounded". */ export interface CampaignBudgetPolicy { /** Campaign-level wall-clock budget, ms. Includes time spent waiting on humans. */ wallClockBudgetMs: number; /** Per-node wall-clock timeout, ms, counted while the node is `running` only. */ nodeTimeoutMs: number; /** * The TH-8-style `WICKED_UNIT_TIMEOUT_SECS` pin every node environment must carry. * An ambient value is HONORED (it is the pin); only when the environment says nothing * does the campaign default apply. Unpinned means the engine's 2-hour default — the * exact unbounded-spend hazard this module exists to close. */ unitTimeoutSecs: number; /** Nightly cap: hard ceiling on schedulable nodes per campaign (fail-closed preflight). */ maxNodes: number; /** Optional campaign cost ceiling, USD. Absent ⇒ wall-clock stays the only hard ceiling. */ maxCostUsd?: number; /** What happens to in-flight nodes at budget exhaustion. */ killPolicy: CampaignKillPolicy; } /** Campaign-proven pin (the 2026-08 studio E2E campaign ran `WICKED_UNIT_TIMEOUT_SECS=900`). */ export declare const DEFAULT_UNIT_TIMEOUT_SECS = 900; /** Default per-node timeout: 30 min ≈ 4× the observed 7m41s governed feature run. */ export declare const DEFAULT_NODE_TIMEOUT_SECS = 1800; /** Default campaign wall-clock budget: 4 h (a 21-node ladder ≈ 2.7 h serial at observed rate). */ export declare const DEFAULT_WALL_CLOCK_BUDGET_SECS = 14400; /** Default nightly node cap (the proven ladder was 21 scenarios; 25 leaves headroom). */ export declare const DEFAULT_MAX_NODES = 25; export declare const DEFAULT_KILL_POLICY: CampaignKillPolicy; /** The env knobs `resolveBudgetPolicy` reads. All crew-side; documented in docs/campaign-budgets.md. */ export declare const ENV_BUDGET_SECS = "WICKED_CAMPAIGN_BUDGET_SECS"; export declare const ENV_NODE_TIMEOUT_SECS = "WICKED_CAMPAIGN_NODE_TIMEOUT_SECS"; export declare const ENV_UNIT_TIMEOUT_SECS = "WICKED_UNIT_TIMEOUT_SECS"; export declare const ENV_MAX_NODES = "WICKED_CAMPAIGN_MAX_NODES"; export declare const ENV_MAX_COST_USD = "WICKED_CAMPAIGN_MAX_COST_USD"; export declare const ENV_KILL_POLICY = "WICKED_CAMPAIGN_KILL_POLICY"; /** * Resolve the budget policy from an environment (defaults ↦ documented ceilings; malformed * values throw — fail closed, TH-8 posture). Pass `process.env` in the daemon; tests pass * a literal. */ export declare function resolveBudgetPolicy(env?: Record): CampaignBudgetPolicy; /** * The TH-8-style environment pins every campaign node MUST launch with. Until the full * TH-8 environment manifest lands, this is the manifest's budget block: the interim * mitigation test-R22 names ("the environment manifest must pin WICKED_UNIT_TIMEOUT_SECS"). */ export declare function nodeEnvPins(policy: CampaignBudgetPolicy): Record; /** * Fail-closed preflight: a node environment must carry the exact `WICKED_UNIT_TIMEOUT_SECS` * pin the policy resolved. Missing ⇒ the engine's 2-hour default applies silently; * mismatched ⇒ the manifest lies about the environment. Both refuse. */ export declare function assertNodeEnvPinned(nodeEnv: Record, policy: CampaignBudgetPolicy): void; /** * Nightly cap, fail-closed (test-R22 interim: "the nightly recipe must cap campaign size * explicitly"). Called with the campaign's node count BEFORE launch; over-cap refuses with * both numbers so the operator sees exactly what to trim or raise. */ export declare function assertWithinNightlyCap(nodeCount: number, policy: CampaignBudgetPolicy): void; /** * The scheduler-facing phase vocabulary, deliberately COARSER than core's `NodeStatus` * (DES-CAMPAIGN-001 §2): supervision needs to know spend posture, not scheduling detail. * `pending` = never launched (Pending/Ready); `running` = consuming a worker slot * (Running); `awaiting-human` = quiet by design, no slot (AwaitingHuman/ReadyToResume); * `terminal` = Completed/Failed/Blocked/Cancelled. */ export type SupervisedNodePhase = 'pending' | 'running' | 'awaiting-human' | 'terminal'; /** One node as the scheduler reports it to a sweep. */ export interface SchedulerNodeView { nodeId: string; phase: SupervisedNodePhase; /** The node's live run id, when one was dispatched (core `node_run_id`). */ runId?: string; } /** * The seam TH-9's campaign adapter implements (over core's `CancelRun` + the TS-bridge * campaign state). Tests drive a fake. Every mutation carries the human-readable reason * so the abort is visible AT THE SCHEDULER too, not only in supervision state. */ export interface SupervisedScheduler { /** The campaign's nodes, right now. Read per sweep, never cached. */ listNodes(campaignId: string): Promise; /** Abort one live node (⇒ core CancelRun on its run). */ cancelNode(campaignId: string, nodeId: string, runId: string | undefined, reason: string): Promise; /** Prevent a never-launched node from ever dispatching. */ excludeNode(campaignId: string, nodeId: string, reason: string): Promise; } /** Why a node was excluded/aborted by supervision. */ export type ExclusionKind = 'campaign-budget' | 'cost-budget' | 'node-timeout'; /** The excluded-with-reason record — the AC's "never silent" is this object existing. */ export interface NodeExclusion { kind: ExclusionKind; reason: string; /** ISO-8601 exclusion time (supervisor clock). */ at: string; /** * How the exclusion was effected: `excluded` = never launched, blocked at dispatch; * `cancelled` = in-flight, killed; `abandoned` = in-flight, left to finish under * `abandon-running` (still excluded from any post-budget launch, still reported). */ applied: 'excluded' | 'cancelled' | 'abandoned'; } /** Per-node cost as recorded in evidence. Wall-clock is supervisor-stamped, always present. */ export interface NodeCost { /** Wall-clock the node spent `running` (ms), summed across observations. */ wallMs: number; /** USD figure reported by the pipeline for this node, when its CLI exposes one. */ usd?: number; inputTokens?: number; outputTokens?: number; } /** Supervision's view of one node. */ export interface NodeSupervision { nodeId: string; phase: SupervisedNodePhase; runId?: string; /** Epoch ms the node was first observed `running`. */ startedAt?: number; cost: NodeCost; exclusion?: NodeExclusion; } /** Campaign-level budget exhaustion record. */ export interface BudgetExhaustion { kind: 'wall-clock' | 'cost'; /** ISO-8601. */ at: string; /** What was spent when the ceiling tripped (ms for wall-clock, USD for cost). */ spent: number; /** The ceiling that tripped (ms for wall-clock, USD for cost). */ budget: number; policyApplied: CampaignKillPolicy; } /** The full supervision state for one campaign — TH-9's `GET /campaigns/:id` merges this. */ export interface CampaignSupervisionState { campaignId: string; policy: CampaignBudgetPolicy; /** Epoch ms supervision began (campaign launch). */ startedAt: number; /** Wall-clock elapsed at the last sweep, ms. */ elapsedMs: number; /** Total recorded USD cost across nodes (absent until any node reports one). */ totalCostUsd?: number; /** Set exactly once, when a campaign-level budget exhausts. The abort IS visible here. */ exhausted?: BudgetExhaustion; nodes: NodeSupervision[]; } /** * Synthetic /ws frame: a campaign-level budget exhausted. LOCAL type like * `WorkerStalledFrame` — NOTE for the next `wicked-crew-api-types` release: add this frame * and `campaignNodeExcluded` to the published contract (forward-additive on the wire). */ export type CampaignBudgetExceededFrame = { type: 'campaignBudgetExceeded'; campaign: string; kind: 'wall-clock' | 'cost'; /** ms for wall-clock, USD for cost. */ budget: number; spent: number; policyApplied: CampaignKillPolicy; /** Node ids excluded/aborted by this exhaustion, for the scoreboard's immediate redraw. */ nodes: string[]; }; /** Synthetic /ws frame: one node was excluded/aborted by supervision (incl. node timeouts). */ export type CampaignNodeExcludedFrame = { type: 'campaignNodeExcluded'; campaign: string; node: string; kind: ExclusionKind; reason: string; applied: NodeExclusion['applied']; }; export interface CampaignSupervisorDeps { scheduler: SupervisedScheduler; /** The /ws fan-out for synthetic frames (`events/bus.ts` `broadcast` in the daemon). */ broadcast: (frame: CampaignBudgetExceededFrame | CampaignNodeExcludedFrame) => void; /** Clock (tests stub it). */ now?: () => number; /** Wired to the daemon's warn logger — an abort is an operator-attention signal. */ log?: (m: string) => void; } /** Default sweep cadence: budgets are minutes-scale; 15 s keeps overshoot ≪ any ceiling. */ export declare const DEFAULT_SUPERVISION_SWEEP_INTERVAL_MS = 15000; export declare class CampaignSupervisor { private readonly deps; private readonly campaigns; private readonly now; private readonly log; private handle; private sweeping; constructor(deps: CampaignSupervisorDeps); /** * Begin supervising a campaign under a policy. Call at launch — the wall clock starts * HERE, not at the first sweep. Re-registering an id is a bug (two clocks, one spend). */ supervise(campaignId: string, policy: CampaignBudgetPolicy): void; /** * Stop ENFORCING (campaign reached a terminal status). State stays readable — deliberately * no delete, or a post-terminal report would be irrecoverable. Also reached automatically * when a sweep observes every node terminal. */ release(campaignId: string): void; /** Record pipeline-reported cost for a node (tokens/USD — wall-clock is supervisor-owned). */ recordNodeCost(campaignId: string, nodeId: string, cost: { usd?: number; inputTokens?: number; outputTokens?: number; }): void; /** The supervision state for one campaign, or undefined when it was never supervised. */ state(campaignId: string): CampaignSupervisionState | undefined; /** One enforcement pass over every supervised campaign. Tests drive it directly. */ sweep(): Promise; /** Arm the periodic sweep. Unref'd — the daemon's server lifecycle owns shutdown. */ start(intervalMs?: number): void; stop(): void; private sweepCampaign; /** * Abort everything the budget no longer covers. Every touched node gets an * excluded-with-reason record; one campaignBudgetExceeded frame + one * campaignNodeExcluded frame per node ride the /ws fan-out; the warn log names them all. */ private exhaust; private excludeOne; private trackNode; private totalCostUsd; } /** Filename of the supervision evidence artifact inside a campaign's evidence dir. */ export declare const SUPERVISION_REPORT_FILENAME = "campaign-supervision.json"; /** * The `campaign-supervision.json` evidence artifact (snake_case like the ledger's * manifests). Per-node cost rows are what the TH-14 scoreboard's cost column diffs * between campaigns — a cost regression shows up exactly like a verdict regression. * Deliberately NOT new fields on wicked-ledger's manifest 2.1: that schema moves through * its owner's release train (ADR 0006); this artifact rides in the same evidence dir. */ export interface CampaignSupervisionReport { report: 'campaign-supervision'; version: 1; campaign_id: string; generated_at: string; policy: { wall_clock_budget_secs: number; node_timeout_secs: number; /** The TH-8-style WICKED_UNIT_TIMEOUT_SECS pin nodes ran under. */ unit_timeout_pin_secs: number; max_nodes: number; max_cost_usd?: number; kill_policy: CampaignKillPolicy; }; started_at: string; elapsed_ms: number; budget: { exhausted: boolean; kind?: 'wall-clock' | 'cost'; at?: string; policy_applied?: CampaignKillPolicy; }; totals: { wall_ms: number; cost_usd?: number; input_tokens?: number; output_tokens?: number; }; nodes: Array<{ node_id: string; phase: SupervisedNodePhase; run_id?: string; cost: { wall_ms: number; usd?: number; input_tokens?: number; output_tokens?: number; }; excluded?: { kind: ExclusionKind; reason: string; at: string; applied: NodeExclusion['applied']; }; }>; } /** Build the evidence report from live supervision state. */ export declare function buildSupervisionReport(state: CampaignSupervisionState): CampaignSupervisionReport; /** * Write the report into a campaign's evidence directory (caller resolves the dir — the * QE pipeline owns evidence placement; crew never writes ledger DOMAIN records, and this * is an artifact file, not a runs/verdicts row). Returns the absolute path written. */ export declare function writeSupervisionReport(evidenceDir: string, report: CampaignSupervisionReport): Promise; //# sourceMappingURL=supervision.d.ts.map