import type { DeployEvent, DeployEventBody, DeployReport, JobPhase } from "./orchestrator.js"; export type JobState = /** `stopRequested` = the deadline watchdog has asked the run to stop at its next step boundary. */ { status: "running"; phase: JobPhase; stopRequested: boolean; } | { status: "done"; overall: DeployReport["overall"]; } /** * Stamped by `scanOnBoot`, and SYNTHESIZED by {@link DeployJobController.readJournal} for any * journal with no terminal line — which is the same thing in almost every case, and not in one: * `append` swallows write failures (see its doc), so a deploy that ran to `live` on a full or * read-only volume never got its terminal line written and reads `interrupted` forever after. * A live process still never produces this state for its OWN job — `status` answers from memory * while the job is running. */ | { status: "interrupted"; }; export interface DeployJobMeta { /** "dpl-" + 8 lowercase hex */ deployId: string; packageId: string; version: string; packageDir: string; budget?: number; /** ISO */ startedAt: string; } export interface JobSnapshot { meta: DeployJobMeta; state: JobState; /** tail, newest last (capped in memory; the journal holds the full history) */ events: DeployEvent[]; /** terminal jobs only */ report?: DeployReport; /** * ISO end stamp, quoted from the terminal journal line — `done` jobs only, and absent for a * journal whose terminal line predates this field. It is what lets the renderer report the * duration a finished job TOOK instead of re-deriving `now - startedAt` on every read, which made * one refusal that took 5s report 48s, 83s and 100s on three successive reads. * * Deliberately NOT published for an `interrupted` job, whatever its journal holds. The only * writer of an interrupted terminal line is {@link DeployJobController.scanOnBoot}, which stamps * the moment it NOTICES the dead journal — start → next restart, not start → stop — and the same * journal read before any boot scan carries no stamp at all. `deploy status` refuses to print a * duration from that figure; publishing it on `--json` would hand a machine consumer the exact * number the human surface calls unprovable, and `endedAt - startedAt` there is the box's * downtime rather than any measurement of the job. The stamp stays in the journal for anyone * debugging the boot scan itself. */ endedAt?: string; } /** What `run` is handed: its own id, narration in, the deadline stop out. Nothing else. */ export interface JobHandle { /** This job's id — the `senpi.deploy.id` span attribute the run's own spans carry. */ deployId: string; emit(event: DeployEventBody): void; shouldAbandon(): boolean; } export interface DeployJobControllerOptions { deploysDir: string; now(): number; log(line: string): void; /** journals retained by the boot scan; default 20 */ keep?: number; } /** * H6: every MCP call a deploy makes is separately deadline-bounded. Owned here, beside the grace it * determines, so the two can never be hand-synced apart; `register.ts` imports it for its own * per-call signals. */ export declare const MCP_CALL_TIMEOUT_MS = 90000; /** * How long the soft stop gets to land at a step boundary before the slot is taken back by force. * * DERIVED, never a bare number: force-terminate releases the single-flight slot while the abandoned * run may still be waiting on a call. Sized past the per-call deadline so that by the time the slot * frees, every call the abandoned run could still be awaiting has already been abandoned by its own * deadline — a NEW deploy never starts its preflight/create while the old job is still parked on a * money-moving call against the same funding waterfall. The headroom covers the abort landing and * the step-boundary check that follows it. * * What this does NOT buy: a deadline bounds the WAIT, not the backend's work (`boundAwait` in * `senpi/client.ts` — the gateway-injected MCP transport accepts no abort option). A create or fund * this job stopped waiting on may still complete server-side after the slot has been handed on. * What the grace guarantees is that the job is no longer *waiting* on a money-moving call when the * slot comes back; the residue is reconciled by the next deploy, not prevented here. * * (This supersedes the plan's locked 60s: 60 < the locked 90s call timeout, so the two locked * defaults were mutually inconsistent.) */ export declare const GRACE_MS: number; /** * The single-flight slot is IN-MEMORY ONLY (`this.live`). It must never be persisted: an * interrupted job belongs to a dead process and leaves the slot free by construction — persisting * it would wedge every deploy on the box after one crash. */ export declare class DeployJobController { private readonly deploysDir; private readonly now; private readonly log; private readonly keep; private live; constructor(opts: DeployJobControllerOptions); /** The running job's identity and progress, or null. The single-flight gate reads this. */ running(): (DeployJobMeta & { phase: JobPhase; stopRequested: boolean; }) | null; /** * Claim the single job slot and fire `run` un-awaited. * * Single-flight is a money invariant, not a convenience: concurrent deploys * share one funding waterfall, so two preflights can both pass while jointly * overdrawing. Throws when a job is already running. The ordinary * `E_DEPLOY_IN_PROGRESS` refusal comes from the gateway's own `running()` * check BEFORE it gets here; this throw is the lost race between that check * and this call, and the gateway catches it and renders the same refusal. * * `capMs` is the job's wall-clock cap (H6). Every MCP call the run makes is separately * timeout-bounded, but a cap is what guarantees the slot comes back: one hung await used to hold * it until the gateway restarted, and a boundary-honored stop flag never protected that case. */ start(meta: Omit, capMs: number, run: (job: JobHandle) => Promise): DeployJobMeta; /** * The running job (from memory) when it is the one being asked about — by id or by omission — * else the newest journal, or the named one. Unknown id → null. * * The live check MUST come first even for an explicit id: a * running job's journal has no terminal line yet, so falling through to {@link readJournal} * would render it `interrupted` — a state only the boot scan may ever assign, and a flat lie * about a job that is actively moving money. */ status(deployId?: string): Promise; /** * C2 carve-out: the ONE sanctioned journal read that feeds the orchestrator — and only as a * LOOKUP KEY. Every id returned here is verified against the live backend (strategy_list * strategyIds:[…]) before reconcile acts on it; the journal never decides anything. * * It exists because a create whose custom name the backend rejected lands under a * backend-assigned name, which the reconcile name match can never find again: without the key, * a re-run creates and funds a second wallet beside the first. * * Returns instance name → journaled created-strategy ids, newest journals first. * * `deployId` scopes the answer to ONE journal. Reconcile deliberately passes nothing: every * journal of the package is a legitimate key source there, because any of them may hold the id * of the wallet a re-run must adopt instead of funding a second one (D-9). The interrupted RENDER * passes its own id, because it answers a narrower question — what did THIS job leave behind — * and a still-live wallet from an earlier completed deploy is not that. Rendering it there would * offer to close a strategy that is running exactly as intended. */ priorCreatedStrategyIds(packageId: string, deployId?: string): Map; /** * The second D-9 lookup input off the SAME create-key event: strategyId → the amount that create * call asked the backend to fund. * * Narrower than {@link priorCreatedStrategyIds} in what it may do, and deliberately so. It feeds * exactly one thing — the report's funded-vs-requested comparison — and only the REQUESTED half * of it; the funded half is always re-read live from `strategy_list` on the current run. No * orchestration branch reads it, so a stale, missing or wrong entry costs a warn, never an action. * * It exists because the ask lives only in the memory of the run that funded. A deploy that funds * $60 of a $500 ask and dies before assembling its report resumes as an adoption, and without * this the shortfall is simply never mentioned again. * * Newest journal wins on a repeated strategyId — the ask that most recently produced that wallet. */ priorRequestedFunding(packageId: string): Map; /** * Boot janitor: stamp every unterminated journal `interrupted`, then prune to * the newest `keep`. Discovery is pull-based — the next `openclaw senpi deploy status` * renders the interruption; nothing is resumed or notified here. */ scanOnBoot(): Promise; private recordEvent; /** * Close a job out. The slot is released in `finally`: a journal-write failure is narration lost, * never a wedged single-flight slot that would refuse every future deploy with a stale * `E_DEPLOY_IN_PROGRESS`. */ private terminate; private journalPath; /** * Append one journal line. **Never throws**: the journal is narration, and the narration-only * rule cuts both ways — a full or read-only state volume must not be able to change what a * deploy does (or wedge its slot). A failed write is logged and the line is lost. */ private append; private journalIds; private readLines; /** Journals newest-first by header `startedAt` — the only ordering the controller trusts. */ private journalIdsNewestFirst; private newestJournalId; /** * Journal → snapshot. A journal with no terminal line IS an interrupted job. * * The FIRST terminal line wins. That ordering is load-bearing: when the deadline watchdog * force-terminates a wedged job, the zombie `run` promise may settle later and append a second * terminal line — the watchdog's verdict is the true one, and reading the first keeps it. */ private readJournal; /** Retain the newest `keep` journals. Runs on every terminate and on boot, so a long-lived * gateway does not grow `deploys/` without bound between restarts. Never throws. * * Unlocked by design: prune and every journal read are fully synchronous on one thread, so they * cannot interleave in-process; a torn last line is already tolerated by `readLines`. Do not add * locking without a cross-process caller. */ private prune; } //# sourceMappingURL=job.d.ts.map