/** * Daemon-driven v3 run — the Feishu-product execution path (vs `cli-run.ts`'s * dev/dogfood terminal path). Mirrors v0.2's `driveWorkflowRun`, but for the * v3 engine and with **suspend-mode gates**: * * - `gateMode:'suspend'`: when a humanGate is reached the runtime writes the * pending wait file + returns `awaitingGate` WITHOUT awaiting a decision — * no in-memory promise to lose on a daemon restart. * - This driver posts the approval card(s) for each pending gate and returns. * It does NOT hold the run. A card click resolves the wait (+ appends * `gateResolved`) and RE-INVOKES `driveV3Run` for a fresh replay that picks * up the now-`gateCleared` node and continues. * * Stateless by design (recovery source = runDir dag/journal/wait/chatBinding). * The daemon owns a lightweight per-runId in-flight guard around this so two * concurrent clicks / start can't double-spawn. */ import { type BotConfig } from '../../bot-registry.js'; import { type V3Dag } from './dag.js'; import { type V3RuntimeDeps, type V3RunOutcome, type V3PendingGate } from './runtime.js'; import { type GrillState, type RunChatBinding } from './grill-state.js'; import { type V3RunEnvelope } from './run-envelope.js'; import { type GateWaitStatus } from './human-gate.js'; import { type StoredEvent, type V3ErrorClass } from './journal.js'; import { type GoalAsk, type ValidateManifest } from './contract.js'; /** * runId → runDir with a path-traversal guard (codex review #2). runIds reach * the daemon from outside (start IPC, card clicks) — never trust them into a * `join` without the allowlist check, so the guard lives in core (not glue). */ export declare function safeRunDir(baseDir: string, runId: string): string; export interface V3RunExecutionContext { dag: V3Dag; binding?: RunChatBinding; /** Present for immutable envelope-backed ad-hoc/saved runs. */ botSnapshots?: Map; envelope?: V3RunEnvelope; resolvedWorkflowData?: { params: Record; context: Record; }; /** False only for the one-version legacy grill fallback. */ authorizedArtifacts: boolean; } export type V3RunStartPreflight = { ok: true; context: V3RunExecutionContext; /** Compatibility detail for old callers/tests; saved runs have no grill. */ grill?: GrillState & { dagPath: string; }; } | { ok: false; error: 'no_grill_state' | 'dag_not_approved' | 'approved_dag_missing' | 'approved_dag_invalid' | 'run_envelope_invalid' | 'run_source_not_daemon_startable'; status?: GrillState['status']; detail?: string; }; /** * Mutation authorization boundary for card/CLI recovery actions. * * Envelope-backed runs re-verify every pinned artifact immediately before a * wait file or journal can change. Only a genuinely missing run.json may use * the one-release legacy path, which still requires directory, journal, grill, * and DAG identities to agree. An existing invalid/tampered envelope never * falls back to legacy state. */ export declare function assertV3RunIntegrityForMutation(runDir: string): void; /** * Gate-2 authorization seam shared by the daemon IPC and the actual driver. * Checking only `dagPath` is insufficient: architect writes that path while * the run is still `dag_ready`, before the user has approved the DAG. */ export declare function preflightV3RunStart(runDir: string): V3RunStartPreflight; /** Envelope-first binding lookup shared by daemon routes, card handlers, and * cold attach. Corrupt run.json fails closed; only a genuinely missing * envelope may use the one-version grill fallback. */ export declare function readV3RunChatBinding(runDir: string): RunChatBinding | undefined; /** Verified DAG for recovery/display logic, with the same missing-only legacy * fallback as the start path. */ export declare function loadV3RunDagForRecovery(runDir: string): V3Dag | undefined; export type V3TerminalOutcome = Extract; /** What the daemon needs to render a blocked-node retry card. */ export interface V3BlockedInfo { nodeId: string; attemptId: string; errorClass?: V3ErrorClass; errorCode?: string; message?: string; /** Present when the block is a runtime human-ask (errorCode === ASK_HUMAN): * the agent's question → the daemon posts an ask card instead of a plain * retry card. */ ask?: GoalAsk; /** Present when errorCode === 'REVISIT_BUDGET_EXHAUSTED': the ancestor this * node tried to revisit → the daemon posts a revisit-grant card. */ revisitTo?: string; /** No generic retry may mint a fresh idempotency key for this attempt. */ retryForbidden?: 'host-effect-uncertain' | 'revise-workflow-required'; } /** Latest `nodeBlocked` details for a node (card content). Falls back to a * bare nodeId/attempt when the journal has no blocked event (shouldn't * happen for a blocked run, but the card must still render). */ export declare function blockedInfoFor(events: StoredEvent[], nodeId: string): V3BlockedInfo; /** What the daemon needs to render a revisit-budget grant card. Derived from * the blocked event (source/target/attempt) + a re-run budget check (which tier * is exhausted — the card must grant the RIGHT scope, 菲菲 review). Returns * undefined when `nodeId` isn't actually blocked on REVISIT_BUDGET_EXHAUSTED. */ export interface V3RevisitBudgetBlockedInfo { sourceNodeId: string; toNodeId: string; attemptId: string; tier: 'pair' | 'run'; detail: string; } export declare function revisitBudgetBlockedInfoFor(events: StoredEvent[], nodeId: string): V3RevisitBudgetBlockedInfo | undefined; /** What the daemon needs to render an exhausted-loop grant card. */ export interface V3LoopExhaustedInfo { loopId: string; /** The iteration the loop exhausted at (= the grant card's freshness key). */ iteration: number; /** Authored bound — filled when the dag is loadable (display only). */ maxIterations?: number; /** Extra iterations already granted. */ granted: number; /** Last decision detail (e.g. `result.passed=false (iteration 3/3)`). */ detail?: string; } /** Fold the exhausted-loop card content from the journal. Pure. */ export declare function loopExhaustedInfoFor(events: StoredEvent[], loopId: string): V3LoopExhaustedInfo; export interface V3DaemonRunDeps { /** runs root (default ~/.botmux/v3-runs). */ baseDir?: string; /** bot config source (default live bots.json) — injectable for tests. */ loadBots?: () => BotConfig[]; /** Build the ephemeral pool's runNode — injectable for tests. Default = real pool. */ makeRunNode?: (resolveLarkAppSecret: (larkAppId: string) => string | undefined) => V3RuntimeDeps['runNode']; /** Manifest validator — injectable for tests. Default = real readAndValidateManifest wrapper. */ validateManifest?: ValidateManifest; /** Post (or re-post) a humanGate approval card for a pending gate to the bound topic. */ postGateCard: (binding: RunChatBinding, gate: V3PendingGate, runId: string) => Promise; /** Post a blocked-node retry card. Optional — when absent (or no binding), * a blocked outcome falls through to `onTerminal` like failed/succeeded. */ postBlockedCard?: (binding: RunChatBinding, info: V3BlockedInfo, runId: string) => Promise; /** Post an exhausted-loop grant card (+1 iteration). Optional — same * fallthrough semantics as postBlockedCard. */ postLoopGrantCard?: (binding: RunChatBinding, info: V3LoopExhaustedInfo, runId: string) => Promise; /** Post a revisit-budget grant card (+1 revisit). Optional — same fallthrough * semantics as postBlockedCard. Chosen over the plain blocked card when the * block is a `REVISIT_BUDGET_EXHAUSTED`. */ postRevisitGrantCard?: (binding: RunChatBinding, info: V3RevisitBudgetBlockedInfo, runId: string) => Promise; /** Report a terminal run (final card / message). Optional. */ onTerminal?: (runId: string, outcome: V3TerminalOutcome, binding?: RunChatBinding) => Promise; maxParallel?: number; /** Low-latency interrupt delivery. Durable intent always lives in journal. */ cancelSignal?: AbortSignal; } /** * Drive a daemon-side v3 run to its next suspension point (a gate) or terminal. * Returns the runtime outcome. Throws on: missing grill state, no approved * dag, or awaitingGate with no chatBinding (can't post a card). */ export declare function driveV3Run(runId: string, deps: V3DaemonRunDeps): Promise; export type V3GateClickOutcome = { kind: 'resolved'; resolution: 'approved' | 'rejected'; } | { kind: 'already-settled'; status: GateWaitStatus; } | { kind: 'unauthorized'; } | { kind: 'stale-run'; reason: 'terminal' | 'missing' | 'no-wait' | 'stale-node'; }; /** * Resolve a humanGate approval-card click. Idempotent + terminal-safe (codex * review #5): * 1. run terminal / journal missing → `stale-run` (caller toasts, does NOT * redrive — a finished run must not be pulled back to life by a stale card). * 2. wait missing / non-pending → `stale-run`(no-wait) / `already-settled` * (caller toasts, no redrive — guards repeat clicks). * 3. pending → `resolveWait` (atomic — THE idempotency guard) THEN append * `gateResolved`. Returns `resolved` → caller redrives. * * Order is wait-first on purpose: a crash between the two leaves the wait * settled (future clicks → already-settled, no double-resolve); the rare * wait-resolved-but-journal-missing gap is healed by cold-attach reconcile. * If the journal append throws, this throws — the caller must warn and NOT * fake UI success (codex #5). */ export declare function resolveV3GateClick(baseDir: string, runId: string, input: { waitId: string; selected: string; by: string; }): V3GateClickOutcome; export type V3RunCancelOutcome = { kind: 'requested'; cancelRequestId: string; } | { kind: 'already-requested'; cancelRequestId: string; } | { kind: 'already-cancelled'; cancelRequestId?: string; } | { kind: 'already-terminal'; status: 'succeeded' | 'failed'; } | { kind: 'stale-run'; reason: 'missing'; }; /** * Durable, idempotent run-cancel mutation shared by CLI, IM, cards and the * dashboard daemon endpoint. The journal lock is the linearization boundary: * a true terminal committed first wins; otherwise the first cancel request * wins and every repeat reuses its id. */ export declare function requestV3RunCancel(baseDir: string, runId: string, input: { by: string; reason?: string; }): V3RunCancelOutcome; export type V3RetryOutcome = { kind: 'requested'; nodeId: string; previousAttemptId: string; nextAttemptId: string; } | { kind: 'already-requested'; nodeId: string; } | { kind: 'stale-run'; reason: 'missing' | 'not-blocked' | 'stale-attempt' | 'loop-node' | 'invalid-answer' | 'host-effect-uncertain' | 'revise-workflow-required'; }; type V3RetryAnswerInput = { selected: string; by: string; } | { text: string; by: string; }; /** * Append a retry intent for a blocked node (the resume entrypoint — daemon * card click and `botmux workflow retry` both land here). Recovery-first + * idempotent (codex v2 of the blocked design): * 1. fresh `materialize(readJournal)` — the journal is the recovery source; * a node that already succeeded / re-dispatched is seen as such. * 2. the target node must STILL be materialized `blocked`. A node already * reset to pending by an unconsumed `nodeRetryRequested` → already-requested * (no second append); anything else → stale. * 3. `expectedAttemptId` (card clicks pass the card's attempt): the retry is * only valid for the attempt that is CURRENTLY blocked — a stale card from * attempt 001 must not advance attempt 002's blocked to 003 (codex * blocker, slice-1 review). The card nonce alone only proves the card's * own integrity, not freshness. CLI omits it ("retry whatever is blocked"). * 4. append `nodeRetryRequested` with the reserved nextAttemptId and the * previous blocked event's errorClass/errorCode copied in for audit. * The caller re-drives (materialize folds the retry into pending → orchestrator * re-dispatches with the reserved attempt number). */ export declare function requestV3Retry(baseDir: string, runId: string, input?: { nodeId?: string; expectedAttemptId?: string; answer?: V3RetryAnswerInput; }): V3RetryOutcome; export type V3RevisitGrantOutcome = { kind: 'granted'; scope: 'pair' | 'run'; retry: V3RetryOutcome; } | { kind: 'invalid'; reason: 'partial-pair' | 'pair-source-mismatch'; } | { kind: 'stale-run'; reason: 'missing' | 'not-budget-blocked' | 'stale-attempt'; }; /** Grant +1 revisit budget after a run blocked on `REVISIT_BUDGET_EXHAUSTED`, * then resume (the revisit analogue of a loop-iteration grant). Atomic * "continue": append `revisitBudgetGranted` AND retry the blocked node so it * re-attempts its revisit within the extended budget — one entry, like the * card's one-click. Guards (菲菲 review): * - the run MUST currently be blocked on a `REVISIT_BUDGET_EXHAUSTED` node * (freshness + idempotency: after grant+retry the node is pending, so a * repeat call is `not-budget-blocked` and adds NO further budget); * - PAIR grant ⇒ both sourceNodeId+toNodeId, and sourceNodeId MUST be the * blocked node; RUN grant ⇒ neither; a half-filled pair is rejected (never * silently widened to a run grant); * - `expectedAttemptId` (card passes it) must match the blocked attempt. */ export declare function requestRevisitGrant(baseDir: string, runId: string, input: { sourceNodeId?: string; toNodeId?: string; by: string; reason?: string; expectedAttemptId?: string; }): V3RevisitGrantOutcome; export type V3LoopGrantOutcome = { kind: 'granted'; loopId: string; fromIteration: number; nextIteration: number; } | { kind: 'already-granted'; loopId: string; } | { kind: 'stale-run'; reason: 'missing' | 'not-exhausted' | 'stale-iteration'; }; /** * Grant ONE extra iteration to an exhausted-blocked loop (the loop analogue * of `requestV3Retry` — daemon grant-card click and `botmux workflow grant` * both land here). Same recovery-first discipline: * 1. fresh `materialize(readJournal)` — the journal is the only truth. * 2. the target loop must STILL be exhausted-blocked. An unconsumed grant * (`pendingGrant`) → already-granted (idempotent, no second append). * 3. `expectedIteration` (card clicks pass the card's iteration): the grant * is only valid for the iteration the loop exhausted at — a stale card * from an earlier exhaustion must not grant a second silent round * (expectedAttemptId's lesson, ported). CLI omits it. * 4. append `loopIterationGranted`; the caller re-drives (materialize folds * the grant into a running loop → orchestrator starts iteration N+1). */ export declare function requestV3LoopGrant(baseDir: string, runId: string, input?: { loopId?: string; expectedIteration?: number; by?: string; }): V3LoopGrantOutcome; export interface V3GateRecovery { runId: string; runDir: string; binding?: RunChatBinding; /** pending gates whose approval card the daemon should (re)post. */ repost: V3PendingGate[]; /** blocked node whose retry card the daemon should (re)post — covers the * crash window between the `runBlocked` append and the card send. */ repostBlocked?: V3BlockedInfo; /** exhausted loop whose grant card the daemon should (re)post — the loop * flavor of the same crash window. */ repostLoopGrant?: V3LoopExhaustedInfo; /** revisit-budget-exhausted node whose grant card the daemon should (re)post — * the revisit flavor of the same crash window. */ repostRevisitGrant?: V3RevisitBudgetBlockedInfo; /** true when a resolved-but-unjournaled gate was healed → daemon should driveV3Run. */ resume: boolean; } export interface V3GateRunnerDeps { baseDir?: string; /** Post (or re-post) a gate's approval card to its topic. The daemon builds * the card + sends via Lark (kept here so this module has no `im/` import). */ postCard: (binding: RunChatBinding, gate: V3PendingGate, runId: string) => Promise; /** Post (or re-post) a blocked node's retry card. */ postBlockedCard?: (binding: RunChatBinding, info: V3BlockedInfo, runId: string) => Promise; /** Post (or re-post) an exhausted loop's grant card. */ postLoopGrantCard?: (binding: RunChatBinding, info: V3LoopExhaustedInfo, runId: string) => Promise; /** Post (or re-post) a revisit-budget-exhausted node's grant card. */ postRevisitGrantCard?: (binding: RunChatBinding, info: V3RevisitBudgetBlockedInfo, runId: string) => Promise; /** Notify a terminal run (optional, daemon-supplied). */ notifyTerminal?: (binding: RunChatBinding | undefined, runId: string, outcome: V3TerminalOutcome) => Promise; /** Best-effort observability hooks. They must never affect run semantics. */ onDriveBegin?: (runId: string) => void | Promise; onDriveEnd?: (runId: string) => void | Promise; /** runtime deps passthrough (tests inject; daemon uses real pool). */ loadBots?: V3DaemonRunDeps['loadBots']; makeRunNode?: V3DaemonRunDeps['makeRunNode']; validateManifest?: V3DaemonRunDeps['validateManifest']; maxParallel?: number; /** error sink (default: swallow). Daemon passes its logger.warn. */ onError?: (runId: string, err: unknown) => void; } /** * The daemon's v3 gate run-controller: an in-flight-guarded `drive(runId)` * (mirrors v0.2's driveWorkflowRun re-entry) + a `coldAttach()` that re-arms * pending gates on startup. Stateless except the in-flight set — recovery * source is always the runDir. */ export declare function createV3GateRunner(deps: V3GateRunnerDeps): { drive: (runId: string) => Promise; driveDetached: (runId: string) => void; cancelAndDrive: (runId: string, cancelRequestId: string) => void; coldAttach: (ownerLarkAppId?: string) => Promise; }; /** * Cold-attach reconcile (daemon startup, codex review #2/#3). Finds v3 runs * suspended at a humanGate and reconciles the journal↔wait-file atomic window * BOTH ways: * - node `gateWaiting` + wait file MISSING (crash between the `gateDispatched` * append and `writePendingWait`) → re-create the pending wait from the * dag's `humanGate.prompt`, then repost a card. * - node `gateWaiting` + wait RESOLVED (crash between `resolveWait` and the * `gateResolved` append) → append the missing `gateResolved` → resume. * - node `gateWaiting` + wait pending → just repost a card. * Skips terminal runs. Pure file IO + journal append — the daemon decides what * to post / drive from the returned list. */ export declare function reconcileV3PendingGates(baseDir?: string, ownerLarkAppId?: string): V3GateRecovery[]; export {}; //# sourceMappingURL=daemon-run.d.ts.map