/** * The settlement half of the dispatcher — push-result sweeps, orphan * reconciliation, worktree settlement and failure classification recovery * (`daemon.ts` composes; this owns the region). * * Extracted from `daemon.ts` (TerrifiedBug/conductor#705) so the second most * contested region of the composition root is a lane of its own: a settlement * diff must not have to touch the composition root. This is a move, not a * rewrite — behaviour is byte-identical; a later slice reworks the seams. * * The module is a leaf: it never imports `daemon.ts`, and `daemon.ts` never * re-exports it. What it needs from the daemon that only the daemon owns — * the pause-sentinel ops used by the provider-credit recovery — arrives as * fields of the structural {@link SettlementDeps}, wired at the call sites. */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { log, errText, safeEscalate } from "./log.ts"; import { hasContinuationBudget } from "./admission.ts"; import { appendKnowledge } from "./knowledge.ts"; import { UNREADABLE_TREE_FLAG, analyseSettlement, deriveChangedLine, } from "./diff-flags.ts"; import { EFFECTIVE_BUDGET_SAMPLE_RUNS, SPINNING_CAP_CLASSES, COMPOSE_DEPENDENCY_STARTUP_SIGNATURE, classifyRun, normalise, observeTurnBudget, type ClassifyFacts, } from "./failure-class.ts"; import { GhPrMissingError } from "./tracker/github.ts"; import { formatModelsTried, modelsTried } from "./model-fallback.ts"; import { PR_LOOKUP_WINDOW_MS } from "./decisions.ts"; import { wakeDispatch } from "./wake.ts"; import { removeWorktree, salvageWip, type RunPublisher, type SalvageOutcome, } from "./worktree.ts"; import type { Caps, Escalation, FailureClass, FileLane, MergedPrInfo, OpenCloser, PrState, ProjectConfig, RecoveryAction, RunRecord, RunState, SettlementFlag, Store, Tracker, } from "./types.ts"; /** Fleet-wide escalations still need an issue number in the payload; 0 is the * sentinel that reads as "no issue" in every renderer. One per module — * `admission.ts`, `daemon.ts` and this module each hold their own copy. */ const NO_ISSUE = 0; /** * The slice of the daemon's `Deps` settlement reads. Defined here rather than * imported from `daemon.ts` (that would be the cycle this leaf exists to * avoid); the composition root wires it at the call sites, and * `daemon.test.ts`'s shared fake satisfies it (its `deps()` helper gained the * two pause-op passthroughs below when this module landed). * * `isPaused`/`setPaused` are the pause-sentinel ops, owned by the daemon * (the pause cluster has a long list of other consumers that import them from * there); the composition root wires them in. Keeping them off the object is * what lets this module stay a leaf. */ export interface SettlementDeps { project: ProjectConfig; caps: Caps; tracker: Tracker; store: Store; escalate(e: Escalation): Promise; /** Where the harness writes its per-process session logs, read for the * terminal evidence a settling row cannot supply itself (#986). Defaults to * the harness's own location — a test points it at a fixture. */ harnessLogDir?: string; /** Whether the pause sentinel is set, daemon-owned. */ isPaused(project?: string): boolean; /** Writes the pause sentinel, daemon-owned. */ setPaused( paused: boolean, provenance: { source: string; reason?: string }, project?: string, ): void; /** * The dispatch wake fired after a queue-label hand-back (#1041). Production * leaves it absent and {@link swapToQueue} calls the real loopback client; * a test injects a spy, which is the only way to prove the wake fires — the * mutation it follows already committed, so nothing about the store or the * outbox records whether dispatch was woken. */ wake?: (projectName: string) => Promise; } /** What the settlement audit of one green run produced: the advisory flags * (test weakening only — the file-list disclosure is derived, not flagged), * whether the diff was cut short, and the `changed:` line composed from the * PR's own diff. */ export interface SettlementAuditResult { flags: SettlementFlag[]; truncated: boolean; /** The `changed:` file list derived from the PR's diff, present whenever the * diff could be read. Absent means the tree could not be read, and `flags` * then carries exactly {@link UNREADABLE_TREE_FLAG}. */ changedLine?: string; } /** * Audit a worker's own account of its work against the pull request it pushed. * * The thin half of the split #85 established: this fetches, {@link * analyseSettlement} decides. It runs beside {@link verifyPushedGreenClaim} and * shares none of its authority — that function decides a run's state, this one * cannot, by construction. It returns evidence and the caller appends it. * * A diff that cannot be read is a finding (`changed-line-missing`), never an * empty flag list: nothing was derived and nothing was checked, and that must * not read as a clean bill. */ export async function collectSettlementFlags( tracker: Pick, claim: { prUrl?: string; issueText: string; sessionFile?: string; lane?: FileLane }, ): Promise { if (claim.prUrl === undefined) return { flags: [UNREADABLE_TREE_FLAG], truncated: false }; // The diff and the claimed-proof body read in parallel; both are advisory, // and an unreadable either one costs that half of the audit and never a // run's state. const [diff, prBody] = await Promise.all([ tracker.prDiff(claim.prUrl), tracker.prBody(claim.prUrl), ]); if (diff === undefined) return { flags: [UNREADABLE_TREE_FLAG], truncated: false }; // Absent session/transcript stays silent — no command can be checked — // never "every claim is verified", the same "could not read" posture as // `changed-line-missing`. let transcript: string | undefined; if (claim.sessionFile !== undefined) { try { transcript = readFileSync(claim.sessionFile, "utf8"); } catch { transcript = undefined; } } return { flags: analyseSettlement({ issueText: claim.issueText, diff, ...(prBody === undefined ? {} : { prBody }), ...(transcript === undefined ? {} : { transcript }), ...(claim.lane === undefined ? {} : { lane: claim.lane }), }), truncated: diff.truncated, changedLine: deriveChangedLine(diff), }; } // ------------------------------------------------------------------- pushed-green settlement /** What a resolved PR turns its `pushed-green` row into. */ export interface Settlement { state: "merged" | "failed"; /** The log line after `# settled: `, and — for a rejection — the row's own * `lastError`, because a `failed` row whose worker succeeded has to say so. */ reason: string; } /** * What one `pushed-green` row becomes now its PR has an answer, or undefined to * leave the row exactly as it is. * * A `pushed-green` row is the only one nothing ever revisited: the worker is * finished, `reconcileOrphanedRuns` only settles rows that held a process, and * `merged` went unwritten from day one. So they accumulated — three of them on * the reference fleet on 2026-08-07, every PR merged and every issue closed, * with `/healthz` still reporting three active runs and their issues * permanently unclaimable, because the busy set *is* the active set (#18). * * The mapping, and why each answer is the only honest one: * * - `merged` — the work landed. That is what `merged` was reserved for. * - `closed` — a human read the work and said no. Leaving it `pushed-green` * forever is a lie; `failed` records that it did not land and releases the * busy guard, so an issue a human re-queues can be attempted again. A row * that had reached `pushed-green` or `pushed-pending` is classified * `returned-for-revision` at settlement. A review decision asks for another * implementation pass, not a failure, so it consumes the continuation budget * instead of the failed-attempt budget. * - `missing` — the claimed PR definitively does not exist (a definitive 404 * from the tracker, never a transient outage). Work was pushed but no PR * ever appeared, so the honest terminal is the same as `closed`: `failed`, * work preserved on the branch and the issue returned to the queue as a * continuation. Nothing retrying can recover — a missing PR does not grow * back (#779). * - `open`, and undefined — nothing changes. Undefined is "could not tell": a * flaky network, a revoked token. Settling on it would record a merge that * never happened, and the next tick asks again for free. An ambiguous answer * must never settle a row. */ export function settlementFor( pr: PrState | "missing" | undefined, prUrl: string, ): Settlement | undefined { if (pr === "merged") return { state: "merged", reason: `${prUrl} merged` }; if (pr === "closed") return { state: "failed", reason: `${prUrl} closed without merging` }; if (pr === "missing") return { state: "failed", reason: `PR ${prUrl} does not exist` }; return undefined; } /** * Records the in-progress label's release as a projection op (#201). * * Settlement used to write only half of what it knew. On 2026-08-09 that cost * the reference fleet two issues in one night: veltro#331 settled to `failed` * at 23:53Z once the orchestrator closed veltro#332 unmerged, and veltro#344 * settled to `merged` at 05:54Z once chad#452 squash-merged. Both rows left the * active set correctly; an authoritative `gh issue view` on each afterwards * still showed `agent:in-progress` — permanently unclaimable with no supported * way back (#18). * * The outbox makes the row transition and the label one fact again: the * removal is enqueued in the same breath as the row is terminalised, the * projector applies it with unbounded retry, and while it is pending the * eligibility overlay treats the label as already gone. A tracker that refuses * the write (403, rate limit) can no longer strand the row — that is `#184` * and `#198` closed. No `pushed-*` row is ever written terminal with its * label release owed but unrecorded, because enqueueing is a local store write * that cannot fail on the tracker. * * Synchronous. The op is durable the moment this returns. */ export function releaseInProgress( d: Pick, issue: number, why: string, ): void { const label = d.project.stateLabels.inProgress; d.store.enqueueLabelOps(d.project.name, [{ issue, op: "remove", label }]); log(`#${issue} released ${label} (queued): ${why}`); } /** * Records the queue label's release as a projection op (#607). * * A settled merge means the work landed. Whether the merge closed the issue or * the PR carried no closing keyword and left it open, the issue must not stay * claimable — and the queue label is the claim gate. Before this function * existed nothing ever took it off a settled issue: the claim path only adds * the in-progress label, so `ready-for-agent` rode along through the run and * outlived the merge. By 2026-08-17 the closed-issue backlog still carrying it * exceeded GitHub's 50-result listing page on this fleet's tracker. * * Same outbox discipline as {@link releaseInProgress}: the removal is a * durable local write enqueued in the same breath as the terminal row, the * projector applies it with unbounded retry, and while it is pending the * eligibility overlay treats the label as already gone. A removal that races a * human's own removal lands as a no-op on the tracker (REST DELETE of an * absent label is idempotent), so it can never resurrect a label the operator * took off. * * Synchronous. The op is durable the moment this returns. */ export function releaseQueueLabel( d: Pick, issue: number, why: string, ): void { const label = d.project.queueLabel; d.store.enqueueLabelOps(d.project.name, [{ issue, op: "remove", label }]); log(`#${issue} released ${label} (queued): ${why}`); } /** * Asks the tracker about every `pushed-green` PR and settles the ones that * resolved. * * Effects at the call site, decision in {@link settlementFor} — the same split * as `checkIntegrity`/`watchOrchestrator`. Exported like `admitCandidates` * rather than kept private, because half of what has to hold is about the sweep * and not the mapping: that a row without a PR costs no API call, and that one * unreachable PR does not stop the others from settling. * * The label is released too, which reverses what this function first promised. * It used to leave tracker labels alone exactly as {@link reconcileOrphanedRuns} * does, reasoning that a merge closes the issue anyway and that deciding what an * issue's labels should say next is the orchestrator's drain duty. There turned * out to be no such path: on 2026-08-09 two settled rows left their issues * carrying `agent:in-progress` forever, with the brief forbidding the * orchestrator from touching it and `unblock` declining to (see * {@link releaseInProgress}). The row transition and the label are one fact, and * writing half of it is the whole of that bug. * * Releasing it is safe here specifically because of what these rows are. A * `pushed-green` or `pushed-pending` row has no process behind it — its worker * exited and its worktree is gone — so a terminal answer about its PR proves no * worker owns the issue, and the duplicate-dispatch interlock the label exists * for is spent. {@link reconcileOrphanedRuns} still leaves labels alone for the * opposite reason: an orphaned `running` row is work nobody has read yet. And * the brief's rule stays absolute, because this is a daemon-owned write through * the same Tracker port the dispatcher claimed the issue with — orphan detection * is only trustworthy while every state label on the tracker came from this * package. * * The two writes are ordered label-then-row, and the order is load-bearing. This * sweep is the only thing that revisits a `pushed-*` row, so the terminal state * is also the row's exit from it: written first, a tracker that then failed on * the label would leave `agent:in-progress` with nothing left to retry it — #18 * exactly, in the last window able to reach it. Writing the label first makes * failure cost a repeated `gh` call on the next tick instead, and the row stays * in the busy set throughout, so no second worker can be sent at the issue while * it waits. */ export async function settlePushedGreen( d: Pick, ): Promise { const { project, tracker, store } = d; // Runs the sweep resolved by terminalising the row. Every terminal write // below increments it; the tick attributes it as `settled` on the pass's // dispatch record, so a held pass is visible as work done, not just as a // clock that moved (#497). let settled = 0; // Filtered from the active set rather than asked for with a new query: active // is live workers plus these, so the list is bounded by the worker cap plus // the number of PRs awaiting a merge — a handful, by construction. A fleet // where that is not a handful has a merge problem, not a dispatch one. const pending = store .activeRuns(project.name) .filter((r) => r.state === "pushed-green" || r.state === "pushed-pending"); for (const run of pending) { // Nothing to ask about. A pushed result requires a PR, so a malformed row // must not buy a `gh` call every five minutes forever. if (run.prUrl === undefined) continue; let pr: PrState | "missing" | undefined; try { pr = await tracker.prState(run.prUrl); } catch (err) { // The adapter throws exactly one classified error: `GhPrMissingError`, // an individual REST 404 the adapter has corroborated with a // same-repository pulls-list read, so the claimed PR definitively does // not exist (#779). That is not "could not tell" — retrying can never // conjure the PR — so the row is settled as a missing PR through the // same mapping as a closed one: work preserved on the branch, busy guard // released, issue back on the queue. A raw 404 that was never // corroborated (a hidden repository, a lost token scope) stays "could // not tell": the instance check is the corroboration, and every other // failure retries next tick, per row — a tracker that throws // unclassified must cost its own row, not the whole sweep. if (err instanceof GhPrMissingError) { pr = "missing"; } else { log(`#${run.issue} not settled: PR state lookup failed (${errText(err)}) — retrying next tick`); continue; } } const settlement = settlementFor(pr, run.prUrl); if (settlement !== undefined) { // A mediated merge enters a second, bounded observation phase. Record the // exact merge commit before the row leaves the active set; if GitHub cannot // supply it yet, retry this settlement next tick rather than create a // merged row whose base result can never be attributed. let merged: MergedPrInfo | undefined; if (settlement.state === "merged") { try { merged = await tracker.mergedPrInfo(run.prUrl); } catch (err) { log(`#${run.issue} not settled: merge identity lookup failed (${errText(err)}) — retrying next tick`); continue; } if (merged === undefined) { log(`#${run.issue} not settled: merge identity unavailable — retrying next tick`); continue; } } // The label removal and the terminal row are one fact again (#201): the // release is enqueued — a durable local write that cannot fail on the // tracker — in the same breath as the row is terminalised. releaseInProgress(d, run.issue, settlement.reason); if (settlement.state === "merged") { // #607: merged work is done — whether the merge closed the issue or // the PR carried no closing keyword and left it open — so the claim // gate comes off too, through the same durable projection. A failed // settlement (closed without merging) keeps the queue label: the // issue is meant to return to the queue for another attempt. releaseQueueLabel(d, run.issue, settlement.reason); } const patch: Partial = { state: settlement.state, endedAt: Date.now(), ...(merged === undefined ? {} : { mergeSha: merged.mergeSha, baseRef: merged.baseRef, baseCheck: "pending", }), }; if (settlement.state === "failed") { patch.lastError = settlement.reason; patch.failureClass = "returned-for-revision"; patch.recoveryAction = "none"; } store.updateRun(run.id, patch); settled += 1; log(`#${run.issue} settled: ${settlement.reason}`); continue; } if (run.state !== "pushed-pending" || pr !== "open" || run.headSha === undefined) continue; let verification; try { verification = await tracker.verifyPr(run.prUrl, run.headSha); } catch (err) { log(`#${run.issue} checks not settled (${errText(err)}) — retrying next tick`); continue; } if (verification === undefined) continue; if (verification.status === "green") { store.updateRun(run.id, { state: "pushed-green", lastError: null }); log(`#${run.issue} checks settled: ${verification.reason}`); } else if (verification.status === "failed") { // Equally terminal, so the release is enqueued before the row writes, // for the same reason as the settlement branch above (see there). The // green branch releases nothing — that row is still awaiting a merge, // and its live PR is exactly the work the label must keep guarding. releaseInProgress(d, run.issue, verification.reason); store.updateRun(run.id, { state: "failed", lastError: verification.reason }); settled += 1; log(`#${run.issue} checks failed: ${verification.reason}`); } else { store.updateRun(run.id, { lastError: verification.reason }); } } return settled; } // ------------------------------------------------------------------- salvaged-PR adoption const ADOPTABLE_PR_STATES: Partial> = { failed: true, killed: true, orphaned: true, blocked: true, }; /** * Reattaches a recovered PR to the terminal run that owns it (#245). * * A worker can fail before its completion report records `prUrl`, then have its * dirty tree committed and pushed by salvage. If that branch already has a PR, * the orchestrator otherwise has no policy-compliant path to inspect or merge * it: ownership is store-backed. Adoption is deliberately stricter than * admission. The tracker query proves the PR closes this run's issue; exact * branch and canonical repository matches prove it is this run's recovered * work, not an unrelated closer. Missing identity is refusal, never a guess. * * The newest run per issue is inspected, at most ten per tick and only inside * the same 30-day window as mediated PR verbs. The cursor advances through the * full eligible set so persistent non-matches cannot starve older recovered * work. Successful adoption is idempotent because the row gains `prUrl`; * non-matches are logged once per daemon process. */ const rejectedSalvagedPrRuns = new Set(); const salvagedPrCursor = new Map(); export async function adoptSalvagedPrs( d: Pick, now = Date.now(), ): Promise { const { project, tracker, store } = d; const eligible = store .recentRuns(project.name, now - PR_LOOKUP_WINDOW_MS) .filter( (run) => ADOPTABLE_PR_STATES[run.state] === true && run.prUrl === undefined && run.branch.trim() !== "", ); const previous = salvagedPrCursor.get(project.name); const previousIndex = previous === undefined ? -1 : eligible.findIndex((run) => run.id === previous); const start = previousIndex === -1 ? 0 : (previousIndex + 1) % eligible.length; const candidates = Array.from( { length: Math.min(SALVAGED_PR_ADOPTION_BATCH, eligible.length) }, (_, offset) => eligible[(start + offset) % eligible.length]!, ); const last = candidates.at(-1); if (last !== undefined) salvagedPrCursor.set(project.name, last.id); for (const run of candidates) { const repo = project.routing.repos[run.repo]; const repoIdentity = repo === undefined ? undefined : githubRepo(repo.cloneUrl); let closers: OpenCloser[]; try { closers = await tracker.openClosersFor(run.issue); } catch (err) { log(`#${run.issue} PR adoption lookup failed (${errText(err)}) — retrying next tick`); continue; } const closer = closers.find( (candidate) => candidate.headRefName !== "" && candidate.headRefName === run.branch && repo !== undefined && candidate.repo !== "" && candidate.repo === repoIdentity, ); if (closer === undefined) { if (!rejectedSalvagedPrRuns.has(run.id)) { const observed = closers[0]; const reason = observed === undefined ? "no open closing PR" : observed.headRefName === "" ? "closer has no head branch identity" : observed.headRefName !== run.branch ? `closer head ${observed.headRefName} does not match retained branch ${run.branch}` : observed.repo === "" ? "closer has no repository identity" : `closer repository ${observed.repo} does not match routed repository`; log(`#${run.issue} PR not adopted onto attempt ${run.attempt}: ${reason}`); rejectedSalvagedPrRuns.add(run.id); } continue; } const flag: SettlementFlag = { kind: "pr-adopted", file: "(recovery)", detail: `${closer.url} matched retained branch ${run.branch} in ${closer.repo}`, }; store.updateRun(run.id, { prUrl: closer.url, settlementFlags: [...(run.settlementFlags ?? []), flag], }); rejectedSalvagedPrRuns.delete(run.id); log( `#${run.issue} adopted PR ${closer.url} onto attempt ${run.attempt}` + (run.salvageSha === undefined ? "" : ` (salvaged head ${run.salvageSha})`), ); } } /** Canonical `owner/repo` identity from a configured network clone URL. */ function githubRepo(cloneUrl: string): string | undefined { const normalized = cloneUrl.replace(/\/$/, "").replace(/\.git$/, ""); const match = /^(?:https?:\/\/[^/]+\/|ssh:\/\/git@[^/]+\/|git@[^:]+:)([^/\s]+\/[^/\s]+)$/.exec( normalized, ); return match?.[1]; } // ------------------------------------------------------------------- worktree settlement /** * Persist the operator-stop transition before releasing its live controller. * The row is terminal first, then its in-progress label is removed through the * same durable projection outbox as every other lifecycle transition. */ export function recordOperatorStop( store: Pick, args: { project: string; issue: number; runId: string; inProgress: string; reason: string; patch: Partial; }, ): void { store.updateRun(args.runId, { ...args.patch, state: "stopped", lastError: `operator stopped: ${args.reason}`, }); store.enqueueLabelOps(args.project, [ { issue: args.issue, op: "remove", label: args.inProgress }, ]); } /** * File a settled worker's reported discoveries into the per-repo knowledge * overlay (Phase 3 inner loop). * * The overlay exists because everything a worker learns about a repo — the real * entry point, the gate that actually proves the change, the fake that looks * like a test — used to die with its run row, so the next worker on the same * repo rediscovered it at the cost of turns. `discoveries` is an optional field * on the worker's own structured settlement, so a run that reports none appends * nothing. * * A knowledge write can never fail a settlement, and that is the whole reason * this is a function rather than an inline call: the settle path is what makes a * run's outcome durable, and losing an outcome because an advisory markdown file * could not be written would trade the fleet's record for a note. `appendKnowledge` * already swallows its own IO faults; the catch here covers everything else * (a path that cannot be resolved, a caller-supplied value that surprises it) * so no future change to that module can reach back and break a settle. */ export function recordWorkerDiscoveries( repo: string, issue: number, discoveries: readonly string[] | undefined, at = Date.now(), ): void { if (discoveries === undefined || discoveries.length === 0) return; try { appendKnowledge(repo, discoveries, { issue, at }); const n = discoveries.length; log(`#${issue} recorded ${n} repo ${n === 1 ? "discovery" : "discoveries"} into the ${repo} knowledge overlay`); } catch (err) { log(`#${issue} repo knowledge for ${repo} was not updated: ${errText(err)}`); } } /** * What a salvage attempt contributes to the escalation: where the work went, or * that it went nowhere. Split from the effects below for the same reason * `checkIntegrity` is — this wording is the whole thing a human acts on, so it * is worth a test holding it, and the sha in it is the only pointer to work * that no longer has any other copy. * * `retained` is not cosmetic. These lines used to promise a tree "kept for * inspection" unconditionally, which was true only because salvage ran only on * the paths that keep one. A blocked run's tree is removed the moment its work * is safely on the branch, and sending an operator to a path this process just * deleted is the same class of mistake as #118 itself. */ export function salvageLines( outcome: SalvageOutcome, worktree: string, retained: boolean, ): string[] { const fate = retained ? `Worktree kept for inspection: ${worktree}` : `Worktree removed: ${worktree}`; if (outcome.kind === "nothing") return [`${fate} — nothing uncommitted to salvage`]; if (outcome.kind === "failed") { return [ `WIP SALVAGE FAILED: ${outcome.error}`, `Uncommitted work in ${worktree} is the only copy of it, so the tree was kept.`, "This issue is held out of dispatch until the tree is recovered by hand and", "`omp-conductor unblock --force` records that you accepted it.", ]; } const where = `WIP committed to ${outcome.branch} @ ${outcome.sha}` + (outcome.pushed ? " and pushed — the work outlives this worktree" : ` but NOT pushed (${outcome.pushError ?? "no reason given"}) — it lives only in this host's mirror`); // Manifest belongs in the escalation too: opening the commit is how the // orchestrator talked itself into scrubbing a worker tree (#38). const n = outcome.files.length; const count = `${n} file${n === 1 ? "" : "s"}`; const manifest = outcome.newPaths.length === 0 ? `${count} (all modifications to tracked paths)` : `${count}; new: ${outcome.newPaths.slice(0, 12).join(", ")}${ outcome.newPaths.length > 12 ? `, … +${outcome.newPaths.length - 12} more` : "" }`; return [where, manifest, fate]; } /** Everything a settled run has to record and say about its worktree. */ export interface WorktreeSettlement { outcome: SalvageOutcome; /** Whether the tree still exists now the run is over. */ retained: boolean; /** Escalation lines naming where the work went. */ lines: string[]; /** Row fields recording the durable ref, or the failure that blocks a re-claim. */ patch: Pick; } /** * Decides what becomes of a finished run's worktree: save the work, then keep * or remove the tree, then say which. * * One function because the two halves are one decision and splitting them is * how #118 happened — the removal at the end of dispatch had no idea whether * anything had been saved, and the salvage at the top of the failure branch had * no idea the blocked branch fell through to a `--force` removal. * * A salvage that *fails* retains the tree whatever the caller asked for. There * was real work, git refused to commit it, and the tree is now the only copy in * existence: deleting it on schedule would be the data loss this whole path * exists to prevent. The issue is held out of dispatch until an operator says * otherwise, because the next attempt's `worktree remove --force` would finish * the job (see `admitCandidates`). * * Exported so a test can drive the real decision against a real git tree. */ export async function settleWorktree( args: { issue: number; attempt: number; /** Clause for the commit subject: "killed by the turns cap", "blocked …". */ ending: string; worktree: string; /** The run's branch, so the pre-removal publish names the right ref. */ branch: string; /** * Publishes the run branch on the privileged side. Required rather than * optional: the run's commits live in a repository of its own, * so a removal that did not publish first would delete the only copy — * which is #121's data loss with one extra step. `undefined` is a visible * decision at the call site, never an omission. */ publish: RunPublisher | undefined; } & ( | /** Terminal-failure and orphan trees are evidence, and are kept even when clean. */ { tree: "keep" } | { tree: "remove"; mirrorPath: string } ), ): Promise { const { issue, attempt, ending, worktree, branch, publish } = args; const outcome = await salvageWip(worktree, issue, attempt, ending, publish); const retained = args.tree === "keep" || outcome.kind === "failed"; if (!retained && args.tree === "remove") { // Before the removal, always — not only when salvage found something. A run // that *committed* and could not publish has its work in its own repository // and nowhere else, and salvage never sees a committed tree because it is // clean. The mirror fetch inside `publish` is what preserves it; the push // to GitHub can fail (no network, protected ref) and the work still lives. const published = await publish?.(branch); if (published !== undefined && !published.ok) { log(`#${issue} publish before removal failed, work is preserved in the mirror: ${published.stderr}`); } await removeWorktree(args.mirrorPath, worktree); } const lines = salvageLines(outcome, worktree, retained); log(`#${issue} salvage: ${lines.join(" ")}`); return { outcome, retained, lines, patch: outcome.kind === "salvaged" ? { salvageSha: outcome.sha } : outcome.kind === "failed" ? { salvageError: outcome.error } : {}, }; } // ------------------------------------------------------------------- orphan & stale-label reconciliation /** Bounded listing per state label, so one reconcile cannot walk a whole repo. */ const RECONCILE_LIMIT = 50; /** * Clear labels from issues that no longer need them (#132's `superseded`). * * Three structural signals, all cheap and all observed on this fleet: an issue * that is closed but still carries an `agent:*` state label, a closed issue * still carrying the queue label — the claim gate on work that can never be * claimed again, which the merge path only recently learned to remove itself * (#607), leaving a 50+-issue backlog — and an open issue carrying `failed` * whose sub-issues have all closed. On 2026-08-09 four issues (#307, * #297, #140, #82) carried `agent:failed` while every one of them was already * complete — the label was residue of a turns-cap kill from two days earlier, * and nothing in the loop ever revisited it. The board counted four phantom * failures while the genuinely stuck issues were invisible. * * The queue label is swept off closed issues only: an open issue carrying it * is claimable work by definition, and a reconcile that guessed would strip * the interlock that keeps two workers off one issue. * * Positive evidence only. A tracker that cannot list answers empty, and an empty * answer removes nothing: a reconcile that guessed would strip the interlock * that keeps two workers off one issue. */ export async function reconcileStaleLabels(d: Pick): Promise { const { project, tracker, store } = d; const labels = [ project.stateLabels.failed, project.stateLabels.blocked, project.stateLabels.inProgress, project.queueLabel, ]; for (const label of labels) { const carrying = await tracker.listLabeled(label, RECONCILE_LIMIT).catch(() => []); for (const issue of carrying) { if (issue.state === "closed") { // Never retain an `agent:*` label on a closed issue: the work is done by // some route, and the label only makes the board lie about it. Enqueue // rather than call — a refused write must not lose the decision; the // projector retries the removal until the tracker takes it (#201). store.enqueueLabelOps(project.name, [{ issue: issue.number, op: "remove", label }]); log(`#${issue.number} reconciled: closed issue no longer carries ${label} (queued)`); continue; } if (label !== project.stateLabels.failed) continue; const children = await tracker.childrenOf(issue.number).catch(() => []); if (children.length === 0 || children.some((c) => c.state !== "closed")) continue; const key = `${project.name}:superseded:${issue.number}`; if (store.wasNotified(key)) continue; const list = children.map((c) => `#${c.number}`).join(", "); store.enqueueLabelOps(project.name, [{ issue: issue.number, op: "remove", label }]); try { await tracker.comment( issue.number, `superseded: all sub-issues closed (${list}) — this label was stale; propose closing if the ` + `acceptance criteria are met on the default branch.`, ); store.markNotified(key); log(`#${issue.number} reconciled: superseded by ${list}`); } catch (err) { // The label removal is already queued and will land regardless; the // comment is the only half that can fail here (#201). log(`#${issue.number} could not comment the superseded note (${errText(err)}) — retrying next tick`); } } } } /** * Retire grooming verdicts whose issue has closed (#964). * * The sibling above says it for labels: *never retain an `agent:*` label on a * closed issue — the work is done by some route, and the label only makes the * board lie about it.* A grooming verdict on a closed issue lies in exactly the * same way, one surface over, and nothing retired one: `reconcileGrooming` * clears only the two per-pass admission holds (`file-lane`, `depends-on`), so * `promotable`, `considered`, `needs-product-decision` and in-flight batch * markers accumulated for the life of the store. * * Measured on this fleet, 2026-08-23: 64 of 65 rows named a closed issue, all 26 * `promotable` rows did, and `status` therefore advertised 26 candidates ready * to promote on a repo whose only open issue was one blocked on an operator. * * Deliberately **not** folded into `reconcileStaleLabels`, though the reasoning * is its sibling's: that function iterates issues *carrying a label*, and a * promotable issue carries none — not the queue label (promotion is what adds * it) and no state label. It would have seen almost none of these rows. * * `listOpenIssues` is the required input and the reason this is safe: it is * complete (paginated to the end), conditional (an unchanged repo costs a 304), * and it *throws* rather than returning a short list. A label-filtered read here * would condemn every verdict it failed to mention. */ export async function reconcileGroomingClosures( d: Pick, ): Promise { const { project, tracker, store } = d; let open: readonly { number: number }[]; try { open = await tracker.listOpenIssues(); } catch (err) { // Fail closed, and loudly enough to notice: retiring on an unread tracker // would delete the whole table the first time GitHub rate-limited us. log(`grooming closure reconcile skipped (${errText(err)}) — retrying next tick`); return; } const retired = store.retireGroomingNotOpen( project.name, open.map((issue) => issue.number), ); if (retired.length === 0) return; log( `grooming reconciled: ${retired.length} verdict(s) retired for closed issues ` + `(${retired.slice(0, 8).map((n) => `#${n}`).join(", ")}${retired.length > 8 ? ", …" : ""})`, ); } /** * Settles `claimed`/`running` rows left by a dead daemon process and, before * marking each one `orphaned`, salvages any dirty worktree. * * Found live after a host restart killed two workers mid-run, and again on * every package deploy that restarted while workers were live (#35): without * the salvage call the next attempt's `worktree remove --force` destroyed * uncommitted edits that had no other copy. Cap-kills already salvaged (#27); * this is the same call site for the restart path. * * Only the rows change. The issue keeps its in-progress label — that label is * the crash guard against double-dispatch, and deciding what a dead worker's * remains are worth (an open PR? a salvaged sha? a clean tree?) is the * orchestrator's drain-duty judgement, not something to automate here. The * rows also keep counting toward `maxAttemptsPerIssue`, so a loop of deaths * still escalates instead of retrying forever. * * `pushed-green` rows are deliberately left alone: they hold no process — they * are finished work waiting on a human merge, and they must keep occupying the * issue so a second attempt cannot land on a live PR. What eventually settles * them is {@link settlePushedGreen}, on the tick, by asking the tracker what * became of the PR — the one question a restart cannot answer by inference. */ export async function reconcileOrphanedRuns( store: Store, project: string, /** * Resolves the privileged publisher for one orphaned run. Optional because a * test driving the row transitions has no repo to publish to; production * always passes it, and without it a salvaged WIP commit stays local — which * is the half of #121 that reaches a human. */ publish?: (run: RunRecord) => RunPublisher, ): Promise { // Live runs only: `pushed-green` holds no process, so it cannot be orphaned by // a process dying — it is finished work waiting on a human merge. const stale = store.liveRuns(project); const endedAt = Date.now(); for (const r of stale) { // Salvage before the row flips: the worktree path is on the record, and // salvageWip is a no-op for a missing/clean tree. The clause matches the // cap-kill wording so triage reads the same either way, and the tree is // kept because an orphan's remains are the orchestrator's drain-duty call. const settlement = r.worktree === "" ? undefined : await settleWorktree({ issue: r.issue, attempt: r.attempt, ending: "killed by a daemon restart", worktree: r.worktree, branch: r.branch, // An orphan's tree is kept, so its commits are not about to be // deleted — but a WIP salvage still has to reach GitHub, which is // #121's whole point and is now the daemon's hop to make. publish: publish?.(r), tree: "keep", }); store.updateRun(r.id, { state: "orphaned", endedAt, ...settlement?.patch }); } return stale; } // ------------------------------------------------------------------- classification & recovery /** A dispatch-infra run (turn-0 git failure) is requeued so the next tick * retries — but only a bounded number of times. Three strikes for one issue * means the mirror itself is broken, not unlucky, and the sweep escalates * instead of burning a turn-0 run per tick forever (#168, #177). */ const DISPATCH_INFRA_MAX_STRIKES = 3; /** A provider-transient requeue (stream stalled mid-run) is retried, but only a * bounded number of times: three aborted streams for one issue means the * provider itself is degraded, not unlucky, and the sweep escalates to a * human instead of requeueing into a down provider forever (#220). */ const PROVIDER_TRANSIENT_MAX_STRIKES = 3; /** A provider-capacity requeue (sustained in-session rate limiting) is retried, * but only a bounded number of times: three throttled runs for one issue mean * the provider is at capacity, not unlucky, and the sweep escalates to a human * instead of requeueing into a throttled provider forever (#573). The issue's * own chain moves onto its next model per strike (via {@link FAILOVER_CLASSES}), * so a bounded chain is exhaustible; this caps the unbounded no-chain case. */ const PROVIDER_CAPACITY_MAX_STRIKES = 3; /** Terminal rows inspected for a salvaged PR per tick. GitHub provenance reads * are maintenance, but a backlog must not turn one tick into an API burst. */ const SALVAGED_PR_ADOPTION_BATCH = 10; /** The failure classes `countContinuations` deliberately does not charge — the * inverted copy of its exclusions, kept beside the breakdown that consumes it * so the two can only drift together (#439). `orphan-clean` is absent on * purpose: daemon orphans consume the continuation budget, which is exactly * why the requeue side has to respect the ceiling instead of racing it. */ const NON_CONTINUATION_CLASSES: Partial> = { "admin-kill": true, "settlement-stuck": true, "env-start-failure": true, "dispatch-infra": true, "provider-credit": true, "provider-transient": true, "provider-capacity": true, }; /** How one issue spent its continuation budget, grouped by failure class — * the exact rows `continuationsFor` charges, so an exhaustion escalation * reports the same budget it says is spent. `unclassified` groups rows that * charged before the class was written (a pre-upgrade NULL). */ function continuationBreakdown(runs: readonly RunRecord[]): Map { const perClass = new Map(); for (const r of runs) { const chargedAsKilledOrOrphaned = (r.state === "killed" || r.state === "orphaned" || r.state === "blocked") && (r.failureClass === undefined || NON_CONTINUATION_CLASSES[r.failureClass] === undefined); const chargedAsReturned = r.state === "failed" && r.failureClass === "returned-for-revision"; if (!chargedAsKilledOrOrphaned && !chargedAsReturned) continue; const cls = r.failureClass ?? "unclassified"; perClass.set(cls, (perClass.get(cls) ?? 0) + 1); } return perClass; } /** The newest attempt's preserved work, if any, so an exhaustion escalation can * say whether continuing is worthwhile: `salvageSha`/`headSha`/`prUrl` are the * three artifacts a run can leave, and all null on a branch nothing reached. */ function newestContinuableRun(runs: readonly RunRecord[]): RunRecord | undefined { const newestFirst = [...runs].reverse(); return newestFirst.find( (r) => r.salvageSha !== undefined || r.headSha !== undefined || r.prUrl !== undefined, ); } /** The fenced-block info string that marks an exhaustion postmortem comment, so * a grooming scout re-slicing the issue can find and parse the whole block by * grepping for it. */ export const POSTMORTEM_MARKER = "conductor-postmortem"; /** How one issue's attempt chain failed, as "3× ci-deterministic, 1× …" — the * digest shape for naming what the exhaustion was. Groups every row by its * failure class, whether or not it charged the continuation budget, because * the postmortem tells the whole story and not just the budget half (#290). */ export function attemptClassBreakdown(runs: readonly RunRecord[]): string { const perClass = new Map(); for (const r of runs) { const cls = r.failureClass ?? "unclassified"; perClass.set(cls, (perClass.get(cls) ?? 0) + 1); } if (perClass.size === 0) return "unclassified"; return Array.from(perClass, ([cls, n]) => `${n}× ${cls}`).join(", "); } /** Wall-clock duration of one run as a compact human string ("45m", "1h30m"). */ export function humanDuration(ms: number): string { const seconds = Math.max(0, Math.round(ms / 1_000)); if (seconds < 60) return `${seconds}s`; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); const rest = minutes % 60; return rest === 0 ? `${hours}h` : `${hours}h${rest}m`; } /** Flatten and bound a run's last error to one greppable table line. */ export function oneLineBrief(text: string | undefined): string | undefined { if (text === undefined || text.trim() === "") return undefined; const flat = text.replace(/\s+/g, " ").trim(); return flat.length > 90 ? `${flat.slice(0, 89)}…` : flat; } /** * The exhaustion postmortem block: one greppable fenced block covering every * attempt in the chain — continuation rows included — with per-attempt turns, * wall clock, failure class and a one-line last error, the explicit salvage * state, the spend total and the transcript paths for local inspection. * * Pure so the tests hold the shape, not the transport: the writer below owns * the once-only guarantee, this owns what "once" looks like. */ export function formatExhaustionPostmortem(args: { issue: number; runs: readonly RunRecord[]; reason: string; }): string { const { issue, runs, reason } = args; const artifact = newestContinuableRun(runs); const totalSpend = runs.reduce((sum, r) => sum + r.spendUsd, 0); const spend = `$${totalSpend.toFixed(2)}`; const attemptLines = runs.map((r) => { const wall = r.endedAt === undefined ? "—" : humanDuration(r.endedAt - r.startedAt); const cls = r.failureClass ?? "unclassified"; const error = oneLineBrief(r.lastError) ?? "—"; return ( ` attempt ${r.attempt} ${r.state.padEnd(12)} turns ${r.turns}/${r.maxTurns} ` + `${cls.padEnd(24)} ${wall.padStart(4)} last error: ${error}` ); }); const salvage = artifact === undefined ? "Salvaged WIP: absent — no attempt preserved a branch, head SHA or pull request." : `Salvaged WIP: present — branch ${artifact.branch} at ${artifact.headSha ?? artifact.salvageSha}` + `${artifact.prUrl === undefined ? "" : ` (PR ${artifact.prUrl})`}.`; return [ `\`\`\`${POSTMORTEM_MARKER}`, `#${issue} exhausted: ${attemptClassBreakdown(runs)}`, reason, "", `Attempts (${runs.length} total, ${spend} spend):`, ...attemptLines, salvage, `Spend total: ${spend} across ${runs.length} attempts.`, "Transcripts:", ...runs.map((r) => (r.sessionFile === undefined ? " (none)" : ` ${r.sessionFile}`)), "```", ].join("\n"); } /** Dedupe key prefix for the exhaustion postmortem comment, per issue. */ function postmortemDedupeKey(project: string, issue: number): string { return `${project}:postmortem:${issue}`; } /** * The exhaustion postmortem: written exactly once per issue, at the point the * continuation budget is spent and the issue is settled toward a human. * * The comment and the material event are both gated by the store's notification * ledger — the same idempotence guard the escalator uses — so re-settling an * already-postmortemed issue posts nothing and records nothing. A body-string * match on the issue would be the wrong guard: an issue re-scoped and re-run * would still carry the old block, and the guarantee asked of this is "decided * once", not "deduped against what is already written". * * The digest must name the exhaustion even if the comment write fails, so the * material event is recorded before the write and unconditionally (the ledger * is append-only, and the gate above already ran once). The comment failure is * logged rather than taking the sweep down with it. */ async function postExhaustionPostmortem(d: Pick, run: RunRecord, reason: string): Promise { const { project, tracker, store } = d; const key = postmortemDedupeKey(project.name, run.issue); if (store.wasNotified(key)) return; const runs = store.runsForIssue(project.name, run.issue); const body = formatExhaustionPostmortem({ issue: run.issue, runs, reason }); const occurredAt = Date.now(); store.recordMaterialEvent({ project: project.name, category: "exhaustion", summary: `#${run.issue} exhausted: ${attemptClassBreakdown(runs)}`, evidence: body, occurredAt, recordedAt: occurredAt, }); try { await tracker.comment(run.issue, body); store.markNotified(key); log(`#${run.issue} posted exhaustion postmortem (${runs.length} attempts)`); } catch (err) { log(`#${run.issue} postmortem comment could not be posted (${errText(err)})`); } } /** * The escalator throws when no transport is configured or Telegram rejects, and * only records the dedup marker on success. A page that cannot be delivered * must not take the tick down with it — log it and let the next tick retry. * * Returns whether it actually went out, because "page once" and "page once * *successfully*" are different promises: a caller that latches a once-only * gate on the attempt turns one failed delivery into permanent silence about a * condition that is still true. */ export async function reactToProviderCredit( d: Pick, issue: number, message: string, sessionFile: string | undefined, ): Promise { const { project } = d; const alreadyPaused = d.isPaused(project.name); if (!alreadyPaused) d.setPaused(true, { source: "provider-credit", reason: message }, project.name); log( `#${issue} provider refused for credit — dispatch ${alreadyPaused ? "remains paused" : "paused"}: ${message}`, ); // Fleet-scoped and run-independent on purpose. The notification ledger // dedupes on `project:issue:tier:summary`, so `NO_ISSUE` plus a summary // carrying no run or attempt pages once for the fleet, not once per run. await safeEscalate(d, { tier: 2, category: "fleet-stopped", project: project.name, issue: NO_ISSUE, summary: `Model provider refused for credit — ${project.name} is paused`, detail: [ message, "", "No implementation attempt was charged: this is a billing state, not a", "failed implementation. Each affected issue keeps its queue label and", "re-dispatches on `omp-conductor resume` once the provider has credit.", `Session: ${sessionFile ?? "(no transcript)"}`, ].join("\n"), }); } /** Bounded per tick: each row costs tracker calls to gather facts for. */ const CLASSIFY_BATCH = 20; /** Tool calls quoted as evidence for a run that spun to its turn cap. */ const SPIN_EVIDENCE_CALLS = 10; /** * The last few tool names a transcript recorded, newest last. * * `turn-cap-spinning` escalates rather than requeueing, and the acceptance * criterion is that the escalation carries evidence of what the worker was doing * when it hit the cap — otherwise the orchestrator opens the transcript and * re-derives it, which is the manual triage this whole sweep removes. */ export function lastToolCalls(sessionFile: string | undefined, limit = SPIN_EVIDENCE_CALLS): string[] { if (sessionFile === undefined) return []; let text: string; try { text = readFileSync(sessionFile, "utf8"); } catch { return []; } const names: string[] = []; for (const line of text.split("\n")) { if (line.length === 0) continue; let row: unknown; try { row = JSON.parse(line) as unknown; } catch { continue; } if (row === null || typeof row !== "object") continue; const rec = row as { readonly [key: string]: unknown }; // Both shapes the harness has written: a top-level tool event, and a tool // block inside an assistant message. const direct = rec["toolName"]; if (typeof direct === "string") { names.push(direct); continue; } const message = rec["message"]; if (message === null || typeof message !== "object") continue; const content = (message as { readonly [key: string]: unknown })["content"]; if (!Array.isArray(content)) continue; for (const part of content) { if (part === null || typeof part !== "object") continue; const p = part as { readonly [key: string]: unknown }; if (p["type"] !== "tool_use") continue; const name = p["name"]; if (typeof name === "string") names.push(name); } } return names.slice(-limit); } /** * The last error a transcript recorded, or undefined when it recorded none. * * The harness writes `{"stopReason":"error","errorStatus":402,"errorId":402, * "errorMessage":"402 This request requires more credits, ..."}`. The daemon * read none of it, so three runs died `unknown` with an empty `lastError` and * charged an attempt each for a billing state (#220). * * Scanned newest-first: a session that recovered from an early error and then * died of something else must report the something else, and a session that * recovered from its only error and finished cleanly reports the error anyway * because there is no terminal verdict to outrank it (#220). */ /** Transcript error shape — the daemon owns the same interface for * `completionLastError`; declared inline here so this leaf never imports it. * * `kind: "provider-stream"` marks a record the harness itself attributed to a * provider abort (see {@link assistantStopError}), so the classifier can trust * the attribution without matching any vendor's wording (#743). */ export type TranscriptError = { status?: number; message: string; kind?: "provider-stream"; }; /** * The provider's own message in a synthetic tool-result record, or undefined * when the record is not the harness attributing a mid-stream provider abort * (#743). * * The harness writes this when the provider dies while the assistant is still * producing and a pending tool call never runs: * * {"type":"message","message":{"role":"toolResult","isError":true, * "toolName":"edit","content":[{"type":"text","text":"Tool call was not * executed because the provider stream ended with an error before the tool * could run: …"}],"details":{"__synthetic":true, * "source":"assistant_stop_error","executed":false, * "upstreamError":"server_error: Upstream error from DeepInfra: * Exception: Response payload is not completed"}}} * * The `source` field is the harness's own marker, so recognition survives any * provider's wording; `upstreamError` (falling back to the record's text) is * the provider's own prose, carried as evidence. * * Deliberately narrower than "any isError toolResult": the marker is what makes * the attribution structural. An `isError` tool result without it is a tool * call that failed on its own — the session's work, not a provider fault — and * must keep falling through. */ function assistantStopError(rec: { readonly [key: string]: unknown }): string | undefined { if (rec["type"] !== "message") return undefined; const message = rec["message"]; if (message === null || typeof message !== "object") return undefined; const msg = message as { readonly [key: string]: unknown }; if (msg["role"] !== "toolResult" || msg["isError"] !== true) return undefined; const details = msg["details"]; if (details === null || typeof details !== "object") return undefined; const detail = details as { readonly [key: string]: unknown }; if (detail["source"] !== "assistant_stop_error") return undefined; const upstream = detail["upstreamError"]; if (typeof upstream === "string" && upstream.trim() !== "") return upstream.trim(); const content = msg["content"]; if (!Array.isArray(content)) return undefined; for (const part of content) { if (part === null || typeof part !== "object") continue; const p = part as { readonly [key: string]: unknown }; if (p["type"] !== "text" || typeof p["text"] !== "string") continue; if (p["text"].trim() !== "") return p["text"].trim(); } return undefined; } /** * Where the harness writes its per-process session logs (#986). * * The harness's own location, not the conductor's: `OMP_CONDUCTOR_HOME` * relocates conductor state and must not move this, because the file being * read belongs to omp and is named by omp. */ export function harnessLogDir(): string { return process.env["OMP_HARNESS_LOG_DIR"] ?? join(homedir(), ".omp", "logs"); } /** The harness's own maintenance-routing markers, in the session log it writes * per process. These name why a session ended when the row cannot: the run * itself records no error, because from its side nothing failed (#986). */ const HARNESS_ROUTE_MARKERS = [ { marker: "empty-stop-retry-cap", terminal: true }, { marker: "empty-stop-handled", terminal: false }, { marker: "bottom-checkCompaction", terminal: false }, { marker: "context-overflow", terminal: true }, ] as const; /** * One bounded line of terminal evidence for a run that did not settle cleanly * (#986), or `undefined` when nothing readable says anything. * * Two surfaces, because the interesting deaths leave nothing on the row: * * - **the transcript's own exit record** — `session_exit` carries the reason * and kind the harness disposed the session with; * - **the harness session log for this run's session-host pid** — where the * provider-flake routes live (`empty-stop-retry-cap`). The path is * deterministic (`/omp...log`, the harness's own * naming), so this reads exactly one file and never scans a directory. * * Bounded on purpose: this string is rendered in `status` beside a surviving * `unknown`, and an operator reading a board does not want a stack trace. */ export function readTerminalEvidence( run: Pick, logDir: string, ): string | undefined { const parts: string[] = []; const exit = readSessionExit(run.sessionFile); if (exit !== undefined) parts.push(exit); const route = readHarnessRoute(run, logDir); if (route !== undefined) parts.push(route); if (parts.length === 0) return undefined; return parts.join("; ").slice(0, 300); } /** The transcript's own last word on how the session ended. */ function readSessionExit(sessionFile: string | undefined): string | undefined { if (sessionFile === undefined) return undefined; let text: string; try { text = readFileSync(sessionFile, "utf8"); } catch { return undefined; } const lines = text.split("\n"); for (let i = lines.length - 1; i >= 0; i -= 1) { const line = lines[i]; if (line === undefined || line.length === 0) continue; let row: unknown; try { row = JSON.parse(line) as unknown; } catch { continue; } if (row === null || typeof row !== "object") continue; const rec = row as { readonly [key: string]: unknown }; if (rec["customType"] !== "session_exit") continue; const data = rec["data"]; if (data === null || typeof data !== "object") continue; const d = data as { readonly [key: string]: unknown }; const reason = typeof d["reason"] === "string" ? d["reason"] : undefined; const kind = typeof d["kind"] === "string" ? d["kind"] : undefined; if (reason === undefined && kind === undefined) continue; return `session_exit ${reason ?? "?"}${kind === undefined ? "" : `/${kind}`}`; } return undefined; } /** * The last maintenance route the harness logged for this run's session-host * process, or `undefined`. Reads one deterministic path and nothing else: a * settle sweep must never walk a log directory whose size is unbounded. */ function readHarnessRoute( run: Pick, logDir: string, ): string | undefined { if (run.workerPid === undefined) return undefined; const day = new Date(run.startedAt).toISOString().slice(0, 10); const path = join(logDir, `omp.${day}.${run.workerPid}.log`); let text: string; try { text = readFileSync(path, "utf8"); } catch { return undefined; } // A terminal failure route remains the cause even when the harness performs // post-agent maintenance afterwards. The live #1001 logs record // `empty-stop-retry-cap` and then `bottom-checkCompaction` milliseconds later; // choosing the last marker hid the failure behind routine bookkeeping. let found: string | undefined; let foundAt = -1; for (const route of HARNESS_ROUTE_MARKERS) { if (!route.terminal) continue; const at = text.lastIndexOf(route.marker); if (at > foundAt) { found = route.marker; foundAt = at; } } if (found !== undefined) return `harness route ${found}`; for (const route of HARNESS_ROUTE_MARKERS) { const at = text.lastIndexOf(route.marker); if (at > foundAt) { found = route.marker; foundAt = at; } } return found === undefined ? undefined : `harness route ${found}`; } export function readSessionError(sessionFile: string | undefined): TranscriptError | undefined { if (sessionFile === undefined) return undefined; let text: string; try { text = readFileSync(sessionFile, "utf8"); } catch { return undefined; } const lines = text.split("\n"); for (let i = lines.length - 1; i >= 0; i -= 1) { const line = lines[i]; if (line === undefined || line.length === 0) continue; let row: unknown; try { row = JSON.parse(line) as unknown; } catch { continue; } if (row === null || typeof row !== "object") continue; const rec = row as { readonly [key: string]: unknown }; // A provider abort mid-stream lands as a synthetic tool-result record, not // a `stopReason:error` row: the pending tool call "was not executed because // the provider stream ended with an error". The harness's own `source` // marker is the attribution — no vendor prose to match — and the provider's // own text rides along as evidence (#743). if (rec["type"] === "message") { const providerText = assistantStopError(rec); if (providerText !== undefined) { return { message: providerText, kind: "provider-stream" }; } } if (rec["stopReason"] !== "error") continue; const message = rec["errorMessage"]; if (typeof message !== "string" || message.trim() === "") continue; const status = rec["errorStatus"]; return { ...(typeof status === "number" && Number.isFinite(status) ? { status } : {}), message: message.trim(), }; } return undefined; } /** * Classify every unclassified terminal run, persist the verdict, and perform the * one recovery its class names (#132). * * Half this fleet's spend produced no merged PR, and every one of those runs * ended at a human who re-derived the same triage by hand and then threw the * conclusion away. The mechanical classes — a cancelled runner, a kill from a * daemon restart, a green PR whose base moved, a row whose PR had already merged * — need no judgement at all; the genuinely human ones are worth a person's * attention only if they arrive with their evidence already gathered. * * Facts are fetched per row and only the ones that row needs: a `killed` row * costs nothing, a `failed` row with a PR costs a state read and a check read. * A `pushed-green` row that classifies to nothing is left completely untouched — * it is healthy, and writing a class onto it would take it out of this sweep for * good. */ export async function classifyAndRecover(d: SettlementDeps): Promise { const { project, caps, tracker, store } = d; const logDir = d.harnessLogDir ?? harnessLogDir(); // Settle recoveries, counted for the pass's dispatch record — a row whose PR // merged is settled here when the settle sweep could not establish identity // (#497). Every other exit returns 0. let settled = 0; // One read for the whole sweep (#1063): the completed-run sample the // effective turn budget is derived from. Each row is then judged against the // budget *excluding itself*, so a just-killed run's own extreme pace cannot // drag the average it is being explained against. const latencySamples = store.recentLatencySamples(project.name, EFFECTIVE_BUDGET_SAMPLE_RUNS); const observedBudgetFor = (runId: string) => observeTurnBudget(latencySamples, caps, project.workerModel, runId); for (const run of store.runsNeedingClassification(project.name, CLASSIFY_BATCH)) { const facts: ClassifyFacts = {}; let classifiedRun = run; if (run.state === "failed" || run.state === "killed") { const sessionError = readSessionError(run.sessionFile); if (sessionError !== undefined) { if (run.lastError === undefined || run.lastError === sessionError.message) { facts.sessionError = sessionError; } if (run.lastError === undefined) { store.updateRun(run.id, { lastError: sessionError.message }); classifiedRun = { ...run, lastError: sessionError.message }; } } // The evidence the row cannot supply itself (#986): the transcript's exit // record and the harness's own routing for this run's process. Recorded // before classification so the table can read it, and recorded even when // it names nothing actionable — "what was known at settle time" is the // question `status` answers beside a surviving `unknown`, and a blank is // only honest when the sweep genuinely found nothing. if (run.terminalEvidence === undefined) { const evidence = readTerminalEvidence(run, logDir); if (evidence !== undefined) { store.updateRun(run.id, { terminalEvidence: evidence }); classifiedRun = { ...classifiedRun, terminalEvidence: evidence }; } } } try { if (run.prUrl !== undefined) { const pr = await tracker.prState(run.prUrl); if (pr !== undefined) facts.pr = pr; if (run.state === "pushed-green" && facts.pr === "open") { facts.mergeable = await tracker.mergeable(run.prUrl); } if (run.state === "failed" && facts.pr === "open") { facts.checks = await tracker.checkConclusions(run.prUrl); // When a check failed with a reachable log, pull its tail so the // table can tell an infra outage (#177) from a real test failure by // the log's own words. First failure wins; a log that cannot be // fetched is left undefined and classification stays conservative. // GitHub reports check states as `FAILURE` while the classifier reads // them lowercased — normalise so the live seam and the table agree on // which check is the failure whose log we pull (#177). const firstFailure = facts.checks.find((c) => normalise(c.state) === "failure" && c.link !== undefined); if (firstFailure?.link !== undefined) { facts.failingLog = await tracker.checkLog(firstFailure.link); } // A Compose dependency-startup failure ("docker compose up" aborted // because a dependency container failed its healthcheck) is only // outside the diff when the PR touched no container configuration — // a PR that breaks its own `docker-compose.yml` produces this exact // sentence and must still charge its attempt (#1059). So the // classifier needs the PR's changed-file list, derived from the // diff this settlement already knows how to fetch // (`Tracker.prDiff`, the same call the settlement audit uses) — // one fetch, and only for a row whose log actually carries // Compose's sentence; every other row pays nothing. A diff that // cannot be read or was cut short (`truncated`) stays undefined: an // unknown list must never waive an attempt. if ( facts.failingLog !== undefined && facts.failingLog.toLowerCase().includes(COMPOSE_DEPENDENCY_STARTUP_SIGNATURE) ) { const diff = await tracker.prDiff(run.prUrl); facts.changedFiles = diff === undefined || diff.truncated ? undefined : diff.files.map((f) => f.path); } } } } catch (err) { // The tracker throws exactly one classified error: `GhPrMissingError`, // an individual REST 404 the adapter corroborated with a // same-repository pulls-list read, so the claimed PR definitively does // not exist (#779). That is a fact, not "could not tell" — retrying can // never conjure the PR — so it is recorded as the absent-PR fact and // classification proceeds instead of leaving the row in // `selectUnclassified` to be offered forever. Everything else (a 5xx, a // revoked token, an opaque 404) is genuinely unreadable and the next // tick asks again for free. if (err instanceof GhPrMissingError) { facts.pr = "missing"; } else { // Per row, like every other sweep here: one unreachable PR must not stop // the rest from being classified. The next tick asks again for free. log(`#${run.issue} not classified: fact gathering failed (${errText(err)}) — retrying next tick`); continue; } } const { cls, recovery, evidence } = classifyRun( classifiedRun, facts, caps, observedBudgetFor(run.id), ); // A healthy green PR is not a failure of any class. Leaving the row // unclassified is what keeps it eligible for the sweep on the tick where its // base does move under it. if (run.state === "pushed-green" && cls === "unknown") continue; // A recovery of `none` is the classifier naming no failure class for the // row at all: a terminal `failed` row whose PR is open and every check is // green is the strongest mechanical success evidence there is short of // merge, so it is not a failed attempt. Restore it to `pushed-green` — the // settle sweep owns the merge decision from there, `failuresFor` stops // counting it, and no `[unknown]` escalation is persisted. Nothing else is // written, so the row stays in the normal `pushed-green` lifecycle // (merge-conflict on a moved base, settle on a merged or closed PR) rather // than being pinned by a class it never had (#766). if (recovery === "none") { store.updateRun(run.id, { state: "pushed-green" }); log(`#${run.issue} restored to pushed-green: ${evidence}`); continue; } const retry = run.failureClass !== undefined; store.updateRun(run.id, { failureClass: cls, recoveryAction: recovery }); log( retry ? `#${run.issue} retrying ${recovery} for ${cls}: ${evidence}` : `#${run.issue} classified ${cls} → ${recovery}: ${evidence}`, ); if (recovery === "settle") settled += 1; await recoverRun(d, classifiedRun, cls, recovery, evidence); } return settled; } /** Performs the one action a class names. Never chooses one of its own. */ async function recoverRun( d: SettlementDeps, run: RunRecord, cls: FailureClass, recovery: RecoveryAction, evidence: string, ): Promise { const { project, caps, tracker, store } = d; const inProgress = project.stateLabels.inProgress; if (recovery === "settle") { // Enqueue the release with the terminal write (see `settlePushedGreen`): // the outbox keeps the label and the row one fact, so a tracker refusal // can no longer strand `agent:in-progress` with nothing left to retry it // (#18, #201). releaseInProgress(d, run.issue, `PR merged: ${evidence}`); // #607: a recovered row whose PR is observed merged is settled work, so // the claim gate comes off with the same projection as the sweep does. releaseQueueLabel(d, run.issue, `PR merged: ${evidence}`); store.updateRun(run.id, { state: "merged", endedAt: Date.now(), recoveredAt: Date.now() }); log(`#${run.issue} settled from ${cls}: ${evidence}`); return; } if (recovery === "continue") { // Two classes recover by continuing, and only one of them has anything left // to do here. // // `turn-cap-progress` was already handed back by the completion path, which // swapped its labels and left the branch retained. There is nothing to // perform, and writing anything would be actively wrong: overwriting // `lastError` with a rebase brief tells the continuation worker to rebase a // run that simply ran out of turns, and re-swapping labels the completion // path already swapped is a pair of no-op `gh` calls. Record-only, so the // sweep stops re-offering it. if (cls === "turn-cap-progress") { store.updateRun(run.id, { recoveredAt: Date.now() }); log(`#${run.issue} already continuing from ${cls}: ${evidence}`); return; } // `wall-clock-cap-progress`: the completion path only auto-continues // turn-cap kills, so a wall-clock kill with work to show reaches this // sweep still holding the failed label. Swap it for the queue — the branch // is retained, so the next dispatch reattaches it and briefs a resume from // the recorded work. Killed rows gathered no tracker facts, so the // issue-open guard mirrors the requeue path's. The continuation gate is // the same one the turns path applies at kill time // (`shouldContinueAfterTurnsCap`): the row is already charged, and once // the ceiling is spent, handing back the queue label would offer a // candidate admission can never accept — only the failed label comes off, // and the exhaustion reaches a human (#490, #348). if (cls === "wall-clock-cap-progress") { // Same split the requeue branch applies (#1080): an issue the tracker // reports closed was resolved by another route — a duplicate, a // supersede, a decomposition — so continuing it would dispatch work // nobody asked for. That verdict is terminal: release the lifecycle // label(s) the row could be holding (the kill path swaps in-progress // for failed; a crash between those two writes leaves either) and // stamp `recoveredAt`, or `selectUnclassified` re-offers this row on // every pass forever (#1095). An unreadable answer is the tracker // failing to answer, not answering no — it stays transient and retries. const state = await tracker.issueState(run.issue).catch(() => undefined); if (state === "closed") { store.enqueueLabelOps(project.name, [ { issue: run.issue, op: "remove", label: project.stateLabels.failed }, { issue: run.issue, op: "remove", label: inProgress }, ]); store.updateRun(run.id, { recoveredAt: Date.now() }); log(`#${run.issue} not continued from ${cls}: issue is closed`); return; } if (state !== "open") { log(`#${run.issue} not continued from ${cls}: issue is unreadable (retrying)`); return; } const continuation = store.continuationsFor(project.name, run.issue); if (hasContinuationBudget(continuation, caps.maxContinuationsPerIssue)) { await swapToQueue(d, run.issue, project.stateLabels.failed); store.updateRun(run.id, { recoveredAt: Date.now() }); log(`#${run.issue} requeued for a wall-clock continuation: ${evidence}`); } else { store.enqueueLabelOps(project.name, [ { issue: run.issue, op: "remove", label: project.stateLabels.failed }, ]); store.updateRun(run.id, { recoveredAt: Date.now() }); await safeEscalate(d, { tier: 1, project: project.name, issue: run.issue, summary: `#${run.issue} exhausted its continuation budget on wall-clock cap kills`, detail: [ `Attempt ${run.attempt} hit the wall-clock cap with work to continue from, but ${continuation} continuation(s) are already charged against a cap of ${caps.maxContinuationsPerIssue}.`, evidence, `Work to continue: branch ${run.branch} at ${run.headSha ?? run.salvageSha}${run.prUrl === undefined ? "" : ` — ${run.prUrl}`}.`, "The issue cannot finish inside the wall-clock cap, so another run would burn a worker slot for the same outcome. Re-scope it, raise maxContinuationsPerIssue for it, or finish the remaining work by hand.", ].join("\n"), }); await postExhaustionPostmortem( d, run, `Attempt ${run.attempt} hit the wall-clock cap with work to continue from, but ${continuation} continuation(s) are already charged against a cap of ${caps.maxContinuationsPerIssue}.`, ); log(`#${run.issue} not continued from ${cls}: continuation budget exhausted`); } return; } // `progress-stall`: the watch settled a hung session (#1086), which keeps // the in-progress label held the way an orphan row does. Work to continue // from hands that label back for a continuation brief; a budget already // spent releases the issue to a human instead of re-offering a candidate // admission can never accept — the same ceiling the orphan path honours. if (cls === "progress-stall") { const state = await tracker.issueState(run.issue).catch(() => undefined); if (state !== "open") { log(`#${run.issue} not continued from ${cls}: issue is ${state ?? "unreadable"}`); return; } const continuation = store.continuationsFor(project.name, run.issue); if (hasContinuationBudget(continuation, caps.maxContinuationsPerIssue)) { await swapToQueue(d, run.issue, inProgress); store.updateRun(run.id, { recoveredAt: Date.now() }); log(`#${run.issue} requeued after a progress stall: ${evidence}`); } else { store.enqueueLabelOps(project.name, [{ issue: run.issue, op: "remove", label: inProgress }]); store.updateRun(run.id, { recoveredAt: Date.now() }); await safeEscalate(d, { tier: 1, project: project.name, issue: run.issue, summary: `#${run.issue} exhausted its continuation budget on progress stalls`, detail: [ `Attempt ${run.attempt} was settled silent with work to continue from, but ${continuation} continuation(s) are already charged against a cap of ${caps.maxContinuationsPerIssue}.`, evidence, `Work to continue: branch ${run.branch} at ${run.headSha ?? run.salvageSha}${run.prUrl === undefined ? "" : ` — ${run.prUrl}`}.`, "Sessions on this issue keep hanging, so another run would burn a worker slot for the same outcome. Re-scope it, raise maxContinuationsPerIssue for it, or finish the remaining work by hand.", ].join("\n"), }); log(`#${run.issue} not continued from ${cls}: continuation budget exhausted`); } return; } // `model-empty-stop`: the provider answered with empty turns until the // harness ended the session. The work is not at fault and the row is not a // failed attempt (the counters exclude the class), so this hands the queue // label back and the next dispatch resumes from whatever the attempt // pushed. Bounded by the continuation budget for the same reason the // wall-clock path is: a provider stuck in that state must reach a human // instead of consuming the issue forever, one free retry at a time. if (cls === "model-empty-stop") { // Same split as the wall-clock branch above (#1080, #1095): a closed // issue is terminal — release the lifecycle label(s), stamp // `recoveredAt`, stop being offered — while an unreadable tracker keeps // the row unstamped and retrying on a later pass. const state = await tracker.issueState(run.issue).catch(() => undefined); if (state === "closed") { store.enqueueLabelOps(project.name, [ { issue: run.issue, op: "remove", label: project.stateLabels.failed }, { issue: run.issue, op: "remove", label: inProgress }, ]); store.updateRun(run.id, { recoveredAt: Date.now() }); log(`#${run.issue} not continued from ${cls}: issue is closed`); return; } if (state !== "open") { log(`#${run.issue} not continued from ${cls}: issue is unreadable (retrying)`); return; } const continuation = store.continuationsFor(project.name, run.issue); if (hasContinuationBudget(continuation, caps.maxContinuationsPerIssue)) { await swapToQueue(d, run.issue, project.stateLabels.failed); store.updateRun(run.id, { recoveredAt: Date.now() }); log(`#${run.issue} requeued after a provider empty-stop: ${evidence}`); return; } store.enqueueLabelOps(project.name, [ { issue: run.issue, op: "remove", label: project.stateLabels.failed }, ]); store.updateRun(run.id, { recoveredAt: Date.now() }); await safeEscalate(d, { tier: 1, project: project.name, issue: run.issue, summary: `#${run.issue} keeps dying to provider empty-stops and has no continuation budget left`, detail: `${evidence}\n\nThe provider ended ${continuation} session(s) on this issue by returning empty turns until the harness's retry cap. That is not the work failing, so no implementation attempt was charged — but the continuation budget is spent, and another free retry would just consume the issue. Switching this issue's model is the usual remedy.`, }); log(`#${run.issue} escalated: provider empty-stops exhausted the continuation budget`); return; } // `merge-conflict`: the branch is retained and its PR is open, so #50's // continuation guard admits it and the next tick briefs a rebase. // // The outbox makes the retry contract one-sided: the swap is enqueued — a // durable local write that cannot fail on the tracker — before // `recoveredAt` is written, so the row can never again be taken out of // `runsNeedingClassification` with its label swap still owed. That was // the defect 0.4.4 claimed to have fixed and did not, for this one // recovery; the projector retries until the tracker takes the swap, and // while it is pending the eligibility overlay keeps the issue coherent // (#201). await swapToQueue(d, run.issue, inProgress); store.updateRun(run.id, { state: "killed", lastError: "merge-conflict: base moved under a green PR — rebase, regenerate recorded artifacts, push without force", recoveredAt: Date.now(), }); log(`#${run.issue} requeued for a rebase continuation: ${evidence}`); return; } if (recovery === "requeue") { if (cls === "provider-credit") { await reactToProviderCredit(d, run.issue, evidence, run.sessionFile); } // A dispatch-infra requeue that keeps landing on the same issue means the // mirror for its repo is persistently broken — a ref-lock that retry already // exhausted, a dead remote. Requeueing forever spends a turn-0 run per tick // with no chance of success, so after a bounded number of strikes this // escalates to a human instead (#168, #177). if (cls === "dispatch-infra" && store.classCountFor(project.name, run.issue, "dispatch-infra") >= DISPATCH_INFRA_MAX_STRIKES) { await safeEscalate(d, { tier: 1, project: project.name, issue: run.issue, summary: `[dispatch-infra] #${run.issue}: the mirror for ${run.repo} is failing persistently — ${evidence}`, detail: [ `The dispatcher could not provision a worktree for #${run.issue} ${DISPATCH_INFRA_MAX_STRIKES} times in a row, all before the worker's first turn.`, "The mirror on this host needs attention (check disk, SSH/HTTPS credentials, and the mirror root).", ].join("\n"), }); store.updateRun(run.id, { recoveredAt: Date.now() }); log(`#${run.issue} escalated from persistent ${cls}: ${evidence}`); return; } // Same bound for provider-transient: an issue whose stream keeps stalling // mid-run is requeued free (no attempt, no continuation charged) — but a // provider that aborts three times for one issue is down, and a human has // to check its status before hand-requeueing (#220). The escalation names // every model the chain tried, so a merged branch built on a different // model is attributable (#286). if ( cls === "provider-transient" && store.classCountFor(project.name, run.issue, "provider-transient") >= PROVIDER_TRANSIENT_MAX_STRIKES ) { const tried = formatModelsTried(modelsTried(store.runsForIssue(project.name, run.issue))); await safeEscalate(d, { tier: 1, project: project.name, issue: run.issue, summary: `[provider-transient] #${run.issue}: the provider keeps aborting mid-stream — ${evidence}`, detail: [ `The provider aborted the stream for #${run.issue} ${PROVIDER_TRANSIENT_MAX_STRIKES} times without the run ever producing a verdict (0 tokens billed each time).`, ...(tried === "" ? [] : [`Models tried: ${tried}.`]), "Check provider status before requeueing by hand.", ].join("\n"), }); store.updateRun(run.id, { recoveredAt: Date.now() }); log(`#${run.issue} escalated from persistent ${cls}: ${evidence}`); return; } // Same bound for provider-capacity: a run the provider throttled into the // ground is requeued free (no attempt charged) — but a provider that // throttles the same issue three times is at capacity, and a human has to // check its status before hand-requeueing (#573). On a chain-configured // project each requeue already moved the next attempt to the next chain // model, so this escalation is what catches the no-chain case and the // exhausted chain; it names every model the chain tried. if ( cls === "provider-capacity" && store.classCountFor(project.name, run.issue, "provider-capacity") >= PROVIDER_CAPACITY_MAX_STRIKES ) { const tried = formatModelsTried(modelsTried(store.runsForIssue(project.name, run.issue))); await safeEscalate(d, { tier: 1, project: project.name, issue: run.issue, summary: `[provider-capacity] #${run.issue}: the model provider is throttling this run into the ground — ${evidence}`, detail: [ `The provider answered #${run.issue} with sustained in-session rate limits ${PROVIDER_CAPACITY_MAX_STRIKES} times in a row; the harness retried each and was exhausted.`, ...(tried === "" ? [] : [`Models tried: ${tried}.`]), "Check the provider's rate-limit status (and its throughput-oriented routes) before requeueing by hand.", ].join("\n"), }); store.updateRun(run.id, { recoveredAt: Date.now() }); log(`#${run.issue} escalated from persistent ${cls}: ${evidence}`); return; } // Only when the tracker still shows this issue as ours to hand back. An // issue that is closed was resolved by another route and requeueing it // would dispatch work nobody asked for — so that verdict is terminal // (#1080): release the dispatcher-owned in-progress label and stamp // `recoveredAt`, exactly like the operator-withdrawal branch below, or the // sweep re-offers this row on every pass forever. An unreadable answer is // a different thing in kind — the tracker failing to answer, not answering // no — so it stays transient and keeps retrying. const state = await tracker.issueState(run.issue).catch(() => undefined); if (state === "closed") { store.enqueueLabelOps(project.name, [{ issue: run.issue, op: "remove", label: inProgress }]); store.updateRun(run.id, { recoveredAt: Date.now() }); log(`#${run.issue} not requeued from ${cls}: issue is closed`); return; } if (state !== "open") { log(`#${run.issue} not requeued from ${cls}: issue is unreadable (retrying)`); return; } // A clean orphan whose queue label is already absent is a deliberate // withdrawal — the operator took the issue out of the queue (e.g. so a // daemon restart could not re-dispatch work known to be unsafe) — and // recovery must not recreate that intent (#423). Read the live label set; // when the queue label is gone, release the dispatcher-owned in-progress // label and stop, leaving the operator's withdrawal to survive recovery. if (cls === "orphan-clean") { const snapshot = await tracker.issueSnapshot(run.issue).catch(() => undefined); if (snapshot === undefined) { log(`#${run.issue} not requeued from orphan-clean: cannot confirm ${project.queueLabel} (unreadable, retrying)`); return; } if (!snapshot.labels.includes(project.queueLabel)) { store.enqueueLabelOps(project.name, [{ issue: run.issue, op: "remove", label: inProgress }]); store.updateRun(run.id, { recoveredAt: Date.now() }); log(`#${run.issue} not requeued from orphan-clean: ${project.queueLabel} removed before recovery (operator withdrawal)`); return; } // #439: an orphan-clean row charges the continuation budget, so once // `hasContinuationBudget` is spent — exactly the predicate `admitCandidates` // holds the issue on — requeueing re-adds a queue label for a candidate the // dispatcher can never admit. Stop handing it back and hold it instead: the // queue label comes off (so admission never re-holds on every dispatch), // the in-progress label is released, and a single diagnosis escalates once. // `orphan-clean` is deliberately NOT a global exclusion from the budget (a // worker that genuinely keeps dying mid-work must still be bounded); this is // the missing ceiling check this path never had (#348's invariant). const continuations = store.continuationsFor(project.name, run.issue); if (!hasContinuationBudget(continuations, caps.maxContinuationsPerIssue)) { const runs = store.runsForIssue(project.name, run.issue); const breakdown = continuationBreakdown(runs); const artifact = newestContinuableRun(runs); const onlyDaemonStops = breakdown.size === 1 && breakdown.get("orphan-clean") === continuations; const classLine = Array.from(breakdown, ([cls, n]) => `${n} ${cls}`).join(", "); const artifactLine = artifact === undefined ? "The attempts left no salvage commit, head SHA or PR — the branch is empty, so start clean from a re-scope rather than continuing from nothing." : `Work to continue: branch ${artifact.branch} at ${artifact.headSha ?? artifact.salvageSha}${artifact.prUrl === undefined ? "" : ` — ${artifact.prUrl}`}.`; store.enqueueLabelOps(project.name, [ { issue: run.issue, op: "remove", label: inProgress }, { issue: run.issue, op: "remove", label: project.queueLabel }, ]); store.updateRun(run.id, { recoveredAt: Date.now() }); await safeEscalate(d, { tier: 1, project: project.name, issue: run.issue, summary: `#${run.issue} exhausted its ${caps.maxContinuationsPerIssue}-continuation budget on ${cls}`, detail: [ `Attempt ${run.attempt} is not requeued: ${continuations} continuation(s) charged against a cap of ${caps.maxContinuationsPerIssue}.`, `Continuations by failure class: ${classLine}.`, onlyDaemonStops ? "Every continuation was a daemon stop — the work never failed; the budget was spent by daemon deaths, not the issue." : "Continuations span real work — inspect what each attempt left behind before continuing.", artifactLine, "What you can do: raise maxContinuationsPerIssue for this issue, re-scope it, or continue from the preserved work (or start clean if none).", ].join("\n"), }); await postExhaustionPostmortem( d, run, `Attempt ${run.attempt} is not requeued: ${continuations} continuation(s) charged against a cap of ${caps.maxContinuationsPerIssue}.`, ); log(`#${run.issue} not requeued from orphan-clean: continuation budget exhausted`); return; } } const label = cls === "orphan-clean" ? inProgress : project.stateLabels.failed; await swapToQueue(d, run.issue, label); store.updateRun(run.id, { recoveredAt: Date.now() }); log(`#${run.issue} requeued from ${cls}: ${evidence}`); return; } if (recovery === "rerun-checks") { if (run.prUrl === undefined) return; try { await tracker.rerunFailedChecks(run.prUrl); } catch (err) { log(`#${run.issue} check re-run failed (${errText(err)}) — retrying next tick`); return; } // Back to pending rather than green: the existing settle sweep re-verifies // it against the recorded head on a later tick, so nothing here has to guess // whether the re-run passed. store.updateRun(run.id, { state: "pushed-pending", lastError: null, recoveredAt: Date.now() }); log(`#${run.issue} re-ran infrastructure checks: ${evidence}`); return; } if (recovery === "escalate") { const detail = [evidence]; const spinning = SPINNING_CAP_CLASSES.includes(cls); if (spinning) { const calls = lastToolCalls(run.sessionFile); detail.push( calls.length === 0 ? "transcript unreadable — no tool calls could be recovered" : `Last ${calls.length} tool calls: ${calls.join(" → ")}`, ); } if (run.lastError !== undefined && cls !== "question") detail.push(run.lastError); // #172: an unwritten transcript is "the run died before it flushed", not a // link to a file the operator will open and find missing. detail.push( run.sessionFile === undefined ? "Session: (no transcript)" : existsSync(run.sessionFile) ? `Session: ${run.sessionFile}` : `Session: ${run.sessionFile} (file was never written — the run died before its transcript was flushed)`, ); // #807: a chain's first artifact-free cap kill is retried once on the // project's stronger tier instead of reaching a human — the capability // recovery the operator asked for. Everything else (no target, a second // cap, a spent continuation budget) falls through and escalates exactly as // it always has, saying in the page why no retry happened. if (spinning) { const outcome = await escalateModelTier(d, run, cls, evidence, detail); if (outcome.requeued) return; detail.push(outcome.note); } // The class and the run are in the summary, which is what the notifications // ledger dedupes on — so one class escalates once per run rather than every // five minutes. await safeEscalate(d, { tier: 1, project: project.name, issue: run.issue, runId: run.id, summary: `[${cls}] #${run.issue} attempt ${run.attempt}: ${evidence}`, detail: detail.join("\n"), }); // The hand-off IS the recovery for these classes: there is nothing else this // package can do, and leaving the row unrecovered would re-escalate forever. store.updateRun(run.id, { recoveredAt: Date.now() }); return; } // `observe` (awaiting-observation), `hold` (orphan-dirty) and `none`: // recorded, nothing performed here. `observe`'s recovery IS the later observation — // the settle sweep re-offers the blocked row until its PR resolves and settles it // the way it settles `settlement-stuck`, so no action exists to perform today and // `recoveredAt` stays NULL by design (#1068). The existing unsalvaged-WIP // admission hold already fails dispatch closed until an operator acknowledges // the tree, which is the only safe move when the worktree holds the only copy // of real work. } /** * The one model escalation an issue chain may spend (#807). * * A run that reached a ceiling with nothing to show is usually a slice that was * too big — which is why the cap classes are excluded from the provider * failover chain and why a second cap stays a decomposition verdict. But the * first one is worth one cheap experiment: the operator configures one stronger * opaque selector, and this hands the chain back to the queue to spend it, * instead of paging a human to do the same thing by hand. * * The marker, the queue hand-back and the row's recovery stamp are one store * transaction (`escalateModel`), so a daemon that dies mid-recovery restarts * into one of exactly two states: nothing happened and the next sweep escalates * cleanly, or all of it happened and the continuation is already owed. There is * no third state where the chain reads as escalated with the continuation it * promised never queued. Every refusal returns a note instead of a silent * fall-through: the page that follows says why no retry happened. * * Deliberately independent of `modelFallbacks`: nothing here reads or advances * the provider chain, and nothing there reads this marker, so a chain that * mixes provider aborts with a cap kill keeps both streaks intact. */ async function escalateModelTier( d: SettlementDeps, run: RunRecord, cls: FailureClass, evidence: string, detail: readonly string[], ): Promise<{ requeued: true } | { requeued: false; note: string }> { const { project, caps, tracker, store } = d; const target = project.workerEscalationModel; if (target === undefined) { return { requeued: false, note: "No model escalation target exists: this project sets no `workerEscalationModel`, " + "so the cap reaches a human exactly as it did before.", }; } const spent = store.modelEscalation(project.name, run.issue); if (spent !== undefined) { return { requeued: false, note: `This chain already spent its one model escalation (${spent.model}, bought by ${spent.failureClass}). ` + "A second cap is a decomposition verdict, not a third tier — re-scope the issue.", }; } // The same two guards the wall-clock continuation applies before handing a // queue label back: a closed issue was resolved by another route, and a spent // continuation budget would offer a candidate admission can never accept // (#490, #348). const state = await tracker.issueState(run.issue).catch(() => undefined); if (state !== "open") { return { requeued: false, note: `Not retried on ${target}: the issue is ${state ?? "unreadable"}.` }; } const continuations = store.continuationsFor(project.name, run.issue); if (!hasContinuationBudget(continuations, caps.maxContinuationsPerIssue)) { return { requeued: false, note: `Not retried on ${target}: ${continuations} continuation(s) are already charged ` + `against a cap of ${caps.maxContinuationsPerIssue}.`, }; } const spending = store.escalateModel({ project: project.name, issue: run.issue, model: target, failureClass: cls, runId: run.id, swapFrom: project.stateLabels.failed, queueLabel: project.queueLabel, }); if (!spending) { return { requeued: false, note: `Not retried on ${target}: this chain's escalation was spent concurrently.`, }; } await safeEscalate(d, { tier: 1, project: project.name, issue: run.issue, runId: run.id, summary: `[${cls}] #${run.issue} attempt ${run.attempt} — retrying once on ${target}: ${evidence}`, detail: [ ...detail, "", `The queue label is back on and the next dispatch runs on ${target}, this chain's one`, "model escalation. Another cap after it is a decomposition verdict, not a third tier.", ].join("\n"), }); log(`#${run.issue} requeued on ${target} after ${cls}: ${evidence}`); return { requeued: true }; } /** * Enqueue a state-label → queue-label swap for projection (#201), then wake * dispatch (#1041). * * The swap is two ops in id order — remove first, then add — which is the * atomicity the projector guarantees: the issue never sits newly eligible * without a queue label on its way back, and the add never lands before the * remove when GitHub fails between them. Enqueueing is a durable local write * that cannot fail on the tracker, so the caller records its recovery * immediately and the projector retries the swap until the tracker takes it — * that closes the 0.4.4 hole where a refused label swap stranded the row * permanently under a log line promising a retry. * * The wake is why this is the chokepoint every requeue goes through. A * continuation handed its queue label back and then waiting out the five-minute * interval reads, from outside, exactly like a stalled queue — the same * complaint #878 fixed for `unblock` and `resume`, on the paths that never * used it. It runs AFTER the enqueue, never before: the wake shortens a sleep * and the pass it prompts re-reads every gate, so a wake that arrives before * the durable write would simply find nothing. And it can never fail the * requeue — the label ops are already committed, the client answers a line for * every outcome including "no daemon", and a thrown seam is logged as the * degradation it is: the next scheduled pass claims. */ export async function swapToQueue( d: Pick, issue: number, label: string, ): Promise { d.store.enqueueLabelOps(d.project.name, [ { issue, op: "remove", label }, { issue, op: "add", label: d.project.queueLabel }, ]); try { log(`#${issue} back on the queue — ${await (d.wake ?? wakeDispatch)(d.project.name)}`); } catch (err) { log(`#${issue} back on the queue but the dispatch wake failed (${errText(err)}) — the next scheduled pass will claim`); } } // ------------------------------------------------------------------- status rendering of salvaged runs /** * The WIP block: every issue whose newest attempt left work behind, and * whether that work is safe. * * Blocked runs used to be invisible here, which is exactly how #118 stayed * invisible for a full attempt cycle — the operator saw a blocked issue and had * no way to tell "stopped with 34 uncommitted files" from "stopped clean". * A preserved line is informational; an UNSALVAGED line is an alarm, and it * names the directory because that directory is the work. */ export function formatSalvagedRuns(runs: readonly RunRecord[]): string[] { if (runs.length === 0) return []; const lines = ["", "wip"]; for (const r of runs) { lines.push( r.salvageError !== undefined && r.salvageAckAt === undefined ? ` #${r.issue} UNSALVAGED ${r.worktree === "" ? "(path not recorded)" : r.worktree} — ` + `only copy, dispatch held (${r.salvageError})` : r.salvageError !== undefined ? ` #${r.issue} accepted as lost attempt ${r.attempt} (${r.salvageError})` : ` #${r.issue} preserved ${r.salvageSha ?? "?"} on ${r.branch} (attempt ${r.attempt}, ${r.state})`, ); } return lines; } /** * The quarantined block (#737): retained worktrees whose object store could * not be made sound, so the daemon refused to fetch into them and their * commits cannot be verified against any remote. Distinct from the `wip` block * on purpose — quarantine is not work the exit left behind, it is a tree the * daemon is refusing to trust, and "potentially stranded" outranks any * wording that reads like routine retention. The line names the directory * because the directory is the work. */ export function formatQuarantinedRuns(runs: readonly RunRecord[]): string[] { if (runs.length === 0) return []; const lines = ["", "quarantined"]; for (const r of runs) { lines.push( ` #${r.issue} QUARANTINED ${r.worktree === "" ? "(path not recorded)" : r.worktree} — ` + (r.quarantineDetail ?? "object store could not be made sound"), ); } return lines; }