/** * The setup wizard: prompts, per-area amend, and the one-writer apply sequence. * * Extracted (#305) so the interview lived behind the `WizardUi` interface rather * than inside the omp plugin's dialog surface; #309 then deleted that surface, and * the terminal `omp-conductor setup` verb is now its only caller. It is * presentation only — it turns a `WizardUi` into questions and, behind one consent * gate, * applies the answers through ./setup.ts which is headless and tested. Every * prompt's shape and default is a decision worth keeping, so none of them were * reworded in the move. */ import { randomUUID } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmdirSync, rmSync, writeFileSync } from "node:fs"; import { platform } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; import { configBackupDir, configPath, expandHome, findProject, loadConfig, resolveCaps, saveConfig, stateDir, writeConfigRaw, } from "./config.ts"; import { claimedTelegramTopics } from "./escalate.ts"; import { hostRamBytes, recommendedMaxWorkers, workerOvercommit } from "./host.ts"; import { admissionAckPath, daemonGeneration, isPaused, pausedPath, pauseInstance, pauseSourceToken, previewProject, readAdmissionAck, setPaused, wakeDaemon, type AdmissionAckRecord, type QueuePreview, } from "./daemon.ts"; import { pauseBytesValid } from "./pause.ts"; import { armedMarkerPath, armTicks, fleetLayers, telegramStateDir } from "./fleet.ts"; import { daemonControlTarget, healthCheck, healthServesProject, livingDaemon, probeUnit, recordPath, restartDaemon, SYSTEMD_UNIT, type RestartResult, } from "./lifecycle.ts"; import { dbPath, liveWorkersReadOnly, openStore, vacuumInto } from "./store.ts"; import { restartFenceProblem, type DaemonIdentity, type DrainDeps, type RestartBegun, type UpgradeScope, } from "./upgrade.ts"; import { defaultGraphRoot, graphRepos } from "./graph.ts"; import { capturePathState, formatHostRuntimePlan, planHostRuntime, totalConfiguredWorkers, restorePathState, runSetupSmoke, SYSTEMD_UNIT_DIR, tickCwdForProject, writeHostRuntime, type CapturedPathState, type HostRuntimePlan, type HostRuntimeWrite, type RestorePathOptions, type SetupSmokeResult, } from "./setup-host.ts"; import { runGraphInstall, runHostInstall, type GraphInstallOptions, type InstallOutcome } from "./setup-install.ts"; import { probeGates, probeProse, probeRepoMap, type ProbedGate, type ProbeTarget } from "./setup-probe.ts"; import { discoverFacts, type DiscoveredFacts, type DiscoveryTarget, } from "./setup-discover.ts"; import { AMEND_AREAS, ARM_PROOF_CHOICES, BASE_FRESHNESS_CHOICES, BEHIND_BASE_CHOICES, DRAFT_POLICY_CHOICES, ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME, RELEASE_REQUIREMENT_CHOICES, REPORT_SCOPE_CHOICES, REVIEW_STRICTNESS_CHOICES, SETUP_DEFAULTS, amendChoices, answersFromProject, briefPathForProject, policyPathForProject, buildConfig, checkTokenScopes, createMissingLabels, defaultAnswers, deleteCreatedLabels, detectTelegram, formatGates, orchestratorBriefPath, parseOmpSettingsYaml, planAgainstLabels, planLabels, readConfiguredOmpRoles, summariseAmend, summarisePlan, wantedLabels, writeOrchestratorBrief, type AmendAreaId, type ConfiguredOmpRoles, type LabelPlan, type OperatorJudgment, type ScopeCheck, type SetupAnswers, type ProbedProse, } from "./setup.ts"; import { ARM_PROOFS, BASE_FRESHNESS, BEHIND_BASE_ACTIONS, DEFAULT_CAPS, DEFAULT_REVIEW_ADJUDICATOR_ROLE, DEFAULT_REVIEW_MAX_ROUNDS, DEFAULT_REVIEW_STRICTNESS, DENIED_RELEASE_GRANTS, DRAFT_POLICIES, RELEASE_REQUIREMENTS, INTERRUPT_CATEGORIES, RELEASE_SHAPES, REVIEW_ADJUDICATOR_RE, REVIEW_MAX_ROUNDS_MAX, REVIEW_MAX_ROUNDS_MIN, REVIEW_STRICTNESS, WEEKDAYS, type AuthorityHolder, type Caps, type ConductorConfig, type OrchestratorMode, type InterruptCategory, type Weekday, type ProjectConfig, type ProjectPolicy, type ReleaseRequirement, type ReportScopeChoice, type ResolvedGrants, type ReviewPolicy, } from "./types.ts"; import { withProgress } from "./ui/progress.ts"; import { modelRolesIn } from "./omp-settings.ts"; import type { WizardUi } from "./wizard-ui.ts"; /** * Dismissing any dialog abandons the whole wizard. * * Thrown rather than returned as a sentinel: the prompt sequence runs to a * dozen questions, and a cancellation check after each one would bury the * shape of the conversation under branching. */ class Cancelled extends Error { constructor() { super("setup cancelled"); this.name = "Cancelled"; } } /** The spelling `config.ts` validates tracker repos against. Checked here too * so a typo is fixed in the dialog instead of in an error an hour later. */ const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; /** * One text answer. The placeholder shows the default and an empty submission * takes it, because the harness has no pre-filled input dialog — so "Enter * accepts what you see" is the contract the whole wizard is built on. */ async function ask(ui: WizardUi, key: string, title: string, fallback: string): Promise { const raw = await ui.input(title, fallback.length > 0 ? fallback : undefined, { key }); if (raw === undefined) throw new Cancelled(); const trimmed = raw.trim(); return trimmed.length > 0 ? trimmed : fallback; } /** * One yes/no answer, with a dismissal treated as leaving rather than as "no". * * Every confirm in this wizard defaults to no, so "no" and "dismissed" look * identical if the surface collapses them — and then Ctrl-C at a yes/no prompt * recorded a silent no and walked on to the next question, which is not what * abandoning a run means. `undefined` is the surface saying the operator left. */ async function askYesNo(ui: WizardUi, key: string, title: string, message: string): Promise { const answer = await ui.confirm(title, message, { key }); if (answer === undefined) throw new Cancelled(); return answer; } /** * Re-asks until the answer passes `check`, which returns the complaint or * `undefined`. Bounded at three tries: a dialog that cannot be escaped is worse * than one that gives up and leaves the config alone. */ async function askValid( ui: WizardUi, key: string, title: string, fallback: string, check: (value: string) => string | undefined, ): Promise { for (let attempt = 0; attempt < 3; attempt++) { const value = await ask(ui, key, title, fallback); const problem = check(value); if (problem === undefined) return value; ui.notify(problem, "warning"); } throw new Cancelled(); } /** A cap. Unparseable input keeps the current value rather than writing a NaN * the validator would later reject — the operator sees why, immediately. */ async function askNumber(ui: WizardUi, key: string, title: string, fallback: number): Promise { const raw = await ask(ui, key, title, String(fallback)); const value = Number(raw); if (!Number.isFinite(value) || value < 0) { ui.notify(`"${raw}" is not a non-negative number — keeping ${fallback}.`, "warning"); return fallback; } return value; } /** * Daily spend ceiling. Blank / "none" / "off" → null (no gate). Unparseable * non-empty input keeps the current value. Distinct from askNumber so operators * can turn the money brake off without writing a magic 0 (which is a hard stop). */ async function askSpendCap( ui: WizardUi, key: string, title: string, fallback: number | null, ): Promise { const seed = fallback === null ? "" : String(fallback); const raw = (await ask(ui, key, title, seed)).trim().toLowerCase(); if (raw === "" || raw === "none" || raw === "off" || raw === "null") return null; const value = Number(raw); if (!Number.isFinite(value) || value < 0) { ui.notify( `"${raw}" is not a non-negative number or blank — keeping ${fallback === null ? "no cap" : fallback}.`, "warning", ); return fallback; } return value; } /** * Pre-push gates as one comma-separated line, `cmd @ cwd` for a subdirectory: * `bun run check, bun test @ server`. Shown through `formatGates`, the same * spelling the amend menu reads a repo's current gates back with. * * ponytail: the ceiling is a command containing a comma or a literal " @ ", * which this would split wrongly. Rare in a lint or test invocation, and the * config file is hand-editable. Upgrade path is `ui.editor()`, a real * multi-line buffer, once someone hits it. */ async function askGates( ui: WizardUi, repoName: string, seed: { cmd: string; cwd: string }[], probed: { cmd: string; cwd: string }[] = [], ): Promise<{ cmd: string; cwd: string }[]> { // A proposal fills the prompt only where the operator has not already decided. // Overwriting gates they typed with a model's reading would be the one place a // probe stopped being a proposal; `probeGates` has already shown them what it // found, so it is on screen either way. const raw = await ask( ui, `pre-push-gates.${repoName}`, `Pre-push gates for ${repoName} — exactly what CI runs, comma separated`, formatGates(seed.length > 0 ? seed : probed), ); const gates: { cmd: string; cwd: string }[] = []; for (const chunk of raw.split(",")) { const entry = chunk.trim(); if (entry.length === 0) continue; const at = entry.lastIndexOf(" @ "); if (at === -1) gates.push({ cmd: entry, cwd: "." }); else gates.push({ cmd: entry.slice(0, at).trim(), cwd: entry.slice(at + 3).trim() }); } if (gates.length === 0) { // Loud, because an unattended push with no gate is how a lint failure // reaches the runners at 03:00 with nobody watching. ui.notify(`No gates for ${repoName} — nothing will be verified before a push.`, "warning"); } return gates; } /** * How loud the orchestrator should be. A list rather than a confirm: "report * scope" has no natural yes, and phrasing it as one would bury which answer * means silence. The cursor starts on the current setting so Enter re-affirms * it, the same contract every other prompt here has. */ async function askReportScope(ui: WizardUi, current: ReportScopeChoice): Promise { const options = REPORT_SCOPE_CHOICES.map((c) => ({ label: c.label, description: c.description })); const at = REPORT_SCOPE_CHOICES.findIndex((c) => c.scope === current); const picked = await ui.select("What should the orchestrator report unprompted?", options, { key: "report-scope", initialIndex: at === -1 ? 0 : at, }); if (picked === undefined) throw new Cancelled(); const choice = REPORT_SCOPE_CHOICES.find((c) => c.label === picked); if (choice === undefined) { // The harness answers with a label we did not offer only if the dialog // contract changed under us; keeping the current scope is the answer that // changes nothing, and it is said out loud rather than assumed. ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning"); return current; } return choice.scope; } /** * One value out of a closed vocabulary, described in the operator's words. * * A select rather than a confirm, and the cursor starts on the configured value * so Enter re-affirms it — the contract every prompt here has. It cannot be a * confirm: a precondition's safe answer is "keep requiring it", and this * harness's confirms always start on no, so a re-run that Entered through them * would quietly relax the gate it was meant to leave alone. * * The label *is* the config value, so the answer the harness hands back needs no * lookup table that could disagree with the vocabulary it was built from. */ async function askLiteral( ui: WizardUi, key: string, title: string, values: readonly T[], described: { readonly [K in T]: string }, current: T, ): Promise { const at = values.findIndex((v) => v === current); const picked = await ui.select( title, values.map((v) => ({ label: v, description: described[v] })), { key, initialIndex: at === -1 ? 0 : at }, ); if (picked === undefined) throw new Cancelled(); const hit = values.find((v) => v === picked); if (hit === undefined) { // The harness answered with a label we never offered, which only happens if // the dialog contract changed under us. Keeping the current value is the // answer that changes nothing, and it is said out loud rather than assumed. ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning"); return current; } return hit; } /** How an empty list is both shown and typed. A word, because a blank line in * this wizard means "accept what you see", not "clear it". */ const EMPTY_LIST = "none"; /** A name list as the prompt shows it and reads it back — one spelling, so the * pre-filled default and the value it round-trips to cannot drift. */ function formatNameList(names: readonly string[]): string { return names.length === 0 ? EMPTY_LIST : names.join(", "); } function parseNameList(answer: string): string[] { if (answer.trim().toLowerCase() === EMPTY_LIST) return []; return answer .split(",") .map((name) => name.trim()) .filter((name) => name.length > 0); } /** Check names, artefacts, environments: open-ended lists this package cannot * enumerate, so the only validation is the shape. */ async function askNameList( ui: WizardUi, key: string, title: string, seed: readonly string[], ): Promise { return parseNameList(await ask(ui, key, title, formatNameList(seed))); } /** * The `requires` set, typed rather than picked one confirm at a time. * * Validated in the dialog against the same array the loader validates against, * and the complaint names every accepted value — an operator who mistyped a * requirement they believed they had set would otherwise find out from a release * that went ahead without it. */ async function askReleaseRequirements( ui: WizardUi, prior: readonly ReleaseRequirement[], ): Promise { const accepted = RELEASE_REQUIREMENTS.join(", "); // The vocabulary, spelled out where it is being asked for. Built from the same // data the validator reads, so a fifth requirement is offered here the moment // it exists rather than staying invisible to everyone who did not read #129. ui.notify( RELEASE_REQUIREMENTS.map((r) => `${r} — ${RELEASE_REQUIREMENT_CHOICES[r]}`).join("\n"), "info", ); const answered = await askValid( ui, "release-requirements", `Release — what must have landed first (any of ${accepted}, comma separated, or "${EMPTY_LIST}")`, formatNameList(prior), (value) => { const unknown = parseNameList(value).filter((name) => !RELEASE_REQUIREMENTS.some((r) => r === name)); return unknown.length === 0 ? undefined : `Not a release requirement: ${unknown.join(", ")}. Accepted: ${accepted}.`; }, ); const chosen = new Set(parseNameList(answered)); // The vocabulary's order, not the operator's: two fleets that require the same // three things must read identically in the plan and in a refusal. return RELEASE_REQUIREMENTS.filter((r) => chosen.has(r)); } /** * The gating conditions #126's verbs read (#129). * * Asked here rather than left to a hand-edit because the whole point of moving * them out of POLICY.md is that they are config: a condition an operator can * only reach by opening `config.json` is one that stays at its default while * their prose says something else, which is the drift this key ended. * * The merge half is always asked — it gates work that lands whatever the * release authority is. The release half only matters when the orchestrator * actually cuts releases: with `release: human` a release verb is refused * before these fields are read (see `verbs/server`), so asking them would be a * dead question. They are skipped, keeping the seed's value. */ async function askPolicyPreconditions( ui: WizardUi, prior: ProjectPolicy, releaseAuthority: AuthorityHolder, ): Promise { const merge = { requiredChecks: await askNameList( ui, "merge-required-checks", `Merge — required checks (comma separated, "${EMPTY_LIST}" = every check the PR reports)`, prior.merge.requiredChecks, ), baseFreshness: await askLiteral( ui, "merge-base-freshness", "Merge — must the PR be level with its base?", BASE_FRESHNESS, BASE_FRESHNESS_CHOICES, prior.merge.baseFreshness, ), drafts: await askLiteral( ui, "merge-drafts", "Merge — draft pull requests", DRAFT_POLICIES, DRAFT_POLICY_CHOICES, prior.merge.drafts, ), whenBehindBase: await askLiteral( ui, "merge-behind-base", "Merge — a green PR that fell behind its base", BEHIND_BASE_ACTIONS, BEHIND_BASE_CHOICES, prior.merge.whenBehindBase, ), }; const release = releaseAuthority === "orchestrator" ? { requires: await askReleaseRequirements(ui, prior.release.requires), requiredChecks: await askNameList( ui, "release-required-checks", `Release — required checks (comma separated, "${EMPTY_LIST}" = every check the branch reports)`, prior.release.requiredChecks, ), artefacts: await askNameList( ui, "release-artefacts", `Release — artefacts this project ships (comma separated, or "${EMPTY_LIST}")`, prior.release.artefacts, ), environments: await askNameList( ui, "release-environments", `Release — environments a deploy may target (comma separated, or "${EMPTY_LIST}")`, prior.release.environments, ), } : prior.release; return { merge, release }; } /** * The release questions a delegated release authority unlocks, in the order the * interview asks them after the grant: one tool gate per release shape, then * the release-policy conditions. * * The authority preview counts and names them from this list — and from * `RELEASE_SHAPES` directly — so the prompt stays honest with the questions * that follow: a shape added to `RELEASE_SHAPES` or a condition added here moves * the number the preview quotes at the same time the later questions appear. A * hard-coded count is the drift this exists to prevent. */ export function releaseUnlockPlan(): { gates: readonly string[]; policy: readonly string[] } { return { // `RELEASE_SHAPES`, not a copy that can rot: these are exactly the per-shape // gates `askReleaseGrants` asks (seven, not five (#500)). gates: [...RELEASE_SHAPES], // The release half of `askPolicyPreconditions`, in the order it asks them. policy: ["requirements", "required checks", "artefacts", "environments"], }; } /** * Who merges, who releases and who promotes. Three confirms rather than one * four-way list: these are independent grants — delegating merges is routine, * delegating releases and promotion is not — and a menu of combinations frames * them as equally ordinary choices, which is exactly the framing a release or * promotion grant must not get. * * Neither confirm can start on "yes", so a re-run that Enters through the * wizard revokes rather than renews. That is the safe direction, and the * current grant is named in the question so the revoke is never a surprise. */ async function askAuthority( ui: WizardUi, prior: ProjectConfig["authority"], ): Promise { const merge = await askYesNo( ui, "merge-authority", "Merge authority", "Delegate PR merging to the orchestrator session? It would land green PRs one at a time, each " + "re-checked against the base branch first. Default: humans merge" + `${prior.merge === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`, ); const unlock = releaseUnlockPlan(); const unlockCount = unlock.gates.length + unlock.policy.length; const release = await askYesNo( ui, "release-authority", "Release authority", "Delegate release cutting to the orchestrator session? It would tag, pin and publish by the " + "procedure you write into its brief — and its brief forbids cutting one before you have. " + `Delegating unlocks ${unlockCount} later decisions: the ${unlock.gates.length} release-tool ` + `gates (${unlock.gates.join(", ")}) and the release-policy conditions (${unlock.policy.join(", ")}). ` + "Declining means humans release and none are asked. Default: humans release" + `${prior.release === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`, ); const promotion = await askYesNo( ui, "promotion-authority", "Promotion authority", "Delegate promotion to the orchestrator session? Promotion is adding the queue label to an " + "issue — the sign-off that lets a worker claim it, and the act that starts spend. " + "Default: the operator promotes" + `${prior.promotion === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`, ); return { merge: merge ? "orchestrator" : "human", release: release ? "orchestrator" : "human", promotion: promotion ? "orchestrator" : "human", }; } /** * What each shape means to the operator being asked about it, in their words * rather than the classifier's. Declared as data over the closed enum so a sixth * shape cannot be added without a question to ask about it — an unasked shape * would silently take the deny default and read as a decision afterwards. * * No fleet-specific vocabulary here (#122): these are generic release acts. * Version preparation becomes executable only when a repo declares its version * file; package names, suite pins and deployment topology still stay in policy. */ const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]: string } = { "version-bump-pr": "open and land an exact version-only pull request before tagging", "git-tag": "create git tags (`git tag v1.2.3`)", "git-push-tags": "push tags to the remote (`git push --follow-tags`)", "package-publish": "publish packages (`npm publish` and equivalents)", "github-release": "create GitHub releases (`gh release create`)", deploy: "deploy — change what is running: kubectl/helm/terraform, a deploy device call, a rollout. " + "This is the one grant that mutates a live environment rather than producing an artifact", // #486: the last leg of the loop. Releasing publishes; installing replaces the // running conductor itself, detached from the session, and only the first // tick after the restart proves it. Fund as deliberate as `deploy` is — the // package floor's "nobody patches the running conductor" is this grant's // deny default. install: "install — replace this host's installed conductor: the discovered CLI package, omp plugin " + "and Herdr plugin pinned to one published release, executed detached from the fleet", }; /** * The questions no probe can answer, because the answers are not in the repo. * * Ported from the onboarding skill, which is explicit about why each one has to * be asked. Today's wizard asks none of them: `askAuthority` and * `askReleaseGrants` record *who holds* each release shape and never the prose * that makes a delegated release safe — and `src/briefs/policy.md` ships stubs * sitting empty because of it. * * Asked before any prose probe runs, so intent always precedes machinery: a * probe's job is to turn these words into the repo's own steps, never to invent * what the operator wants. */ async function askJudgment( ui: WizardUi, grants: ResolvedGrants, prior: OperatorJudgment, roadmapChoices: readonly string[] = [], ): Promise { // Always asked. A tracker shows what is open, never what matters, and an // orchestrator that cannot rank work grooms by recency — which is how a stale // issue outranks the thing being shipped this month. let roadmapSeed = prior.roadmap ?? ""; if (roadmapSeed === "" && roadmapChoices.length > 0) { const other = "Type a different roadmap or priority"; const picked = await ui.select( "Roadmap candidates found in GitHub", [...roadmapChoices.map((label) => ({ label })), { label: other }], { key: "roadmap-candidate", initialIndex: 0 }, ); if (picked === undefined) throw new Cancelled(); roadmapSeed = picked === other ? "" : picked; } const roadmap = await ask( ui, "roadmap", "Where does the roadmap live, and what is the current priority?", roadmapSeed, ); const judgment: OperatorJudgment = { ...prior, ...(roadmap.length === 0 ? {} : { roadmap }) }; const delegated = RELEASE_SHAPES.filter((shape) => grants[shape] === "orchestrator"); if (delegated.length === 0) { // Humans release: there is no boundary to draw and no procedure to write, so // asking would invite prose the config does not grant. return judgment; } if (delegated.length === RELEASE_SHAPES.length) { // Full release. Push back once, concretely, then record what they decide — // it is their fleet. Shown once, never per shape. ui.notify( [ "You have granted the orchestrator every release shape. Two things to weigh:", "", " Credentials. Full release means the session holds publish tokens, registry", " credentials or deploy keys. Those sit in the environment of a session that runs", " unattended for weeks — including a turn that went wrong.", "", " Rollback. An agent that can release owns the 03:00 rollback too, and a rollback", " is a judgement call under time pressure with partial information: the exact", " thing agents are worst at.", "", "Answer the next questions anyway if that is the fleet you want.", ].join("\n"), "warning", ); } // Not "can it release" — *where does its leg stop*. A boundary that cannot be // said in one sentence is not a boundary, and a vague release mandate is what // eventually publishes something at 03:00. The worked example is the skill's, // so an operator has something to calibrate against. judgment.boundary = await ask( ui, "release-boundary", "Where does the orchestrator's leg END? One sentence " + '(a real answer: "at the merged version pin — deploying it is operator territory")', prior.boundary ?? "", ); // All five, because the brief needs each and a missing one is a hole. judgment.releaseWhat = await ask( ui, "release-what", "Release — WHAT may be released, and from which branch?", prior.releaseWhat ?? "", ); judgment.releaseWhen = await ask( ui, "release-when", "Release — WHEN: batched how, after which named checks are green?", prior.releaseWhen ?? "", ); judgment.releaseProof = await ask( ui, "release-proof", "Release — WHAT PROOF must be held first (results actually read, not an impression)?", prior.releaseProof ?? "", ); judgment.releaseAsk = await ask( ui, "release-ask", "Release — what must still be ASKED, every time?", prior.releaseAsk ?? "", ); judgment.releaseForbidden = await ask( ui, "release-forbidden", "Release — what stays permanently FORBIDDEN?", prior.releaseForbidden ?? "force-push, secrets, production data", ); // The batching unit, in the operator's own vocabulary. Without it the // orchestrator either releases per merge — a stream of meaningless versions // burning shared runners — or never releases at all. Distinct from // `RELEASE_REQUIREMENTS`, which are mechanical preconditions, not a unit. judgment.worthCutting = await ask( ui, "release-worth-cutting", "What is a release worth cutting? (a sprint, an epic's children all closed, N merged issues, urgency)", prior.worthCutting ?? "", ); judgment.rollbackOwner = await ask(ui, "rollback-owner", "Who owns the rollback?", prior.rollbackOwner ?? ""); if (namesAPerson(judgment.rollbackOwner)) { // Honour the consequence rather than recording a contradiction: if a person // rolls it back, that person owns the release, and the boundary belongs // before the irreversible step whatever the authority answer sounded like. judgment.rollbackMovesBoundary = await askYesNo( ui, "rollback-moves-boundary", "Rollback owner is a person", `You named "${judgment.rollbackOwner}" as the rollback owner. That person already owns the release, ` + "so the honest configuration puts the orchestrator's boundary *before* the irreversible step — " + "it prepares and verifies, a human performs it. Record the boundary that way?", ); if (judgment.rollbackMovesBoundary) { judgment.boundary = await ask( ui, "release-boundary", "Restate the boundary, ending before the irreversible step", judgment.boundary ?? "", ); } } return judgment; } /** * Whether a rollback answer names a human rather than a mechanism. * * Deliberately crude: the point is to *raise* the consequence for the operator to * accept or decline, and a false positive costs one extra question while a false * negative silently records the contradiction the skill warns about. */ function namesAPerson(answer: string): boolean { const mechanical = ["ci", "workflow", "pipeline", "automation", "the agent", "orchestrator", "nobody", "none", "n/a"]; const trimmed = answer.trim().toLowerCase(); if (trimmed.length === 0) return false; return !mechanical.some((m) => trimmed === m || trimmed.startsWith(`${m} `)); } /** * The mechanical tool gate, one confirm per shape. * * One binary question used to cover all five, which is how #122 happened: an * operator who meant "it may cut a release" also granted "it may deploy to * production", because there was one switch for both. Asking five times is the * point — each answer is a different blast radius. * * No confirm can start on "yes", so a re-run that Enters through the wizard * revokes rather than renews. The current grant is named in the question, so * that revoke is never a surprise. */ async function askReleaseGrants( ui: WizardUi, prior: ResolvedGrants, ): Promise { const grants = { ...prior }; for (const shape of RELEASE_SHAPES) { const open = await askYesNo( ui, `release-tool-gate.${shape}`, `Release tool gate — ${shape}`, `Allow the orchestrator session to ${RELEASE_SHAPE_QUESTIONS[shape]}? Grant this only when the ` + "operator brief carries the procedure it must follow. A worker session is refused this " + "whatever you answer. Default: no" + `${prior[shape] === "orchestrator" ? " — currently granted, answer no to take it back" : ""}.`, ); grants[shape] = open ? "orchestrator" : "human"; } return grants; } /** * Where the session that triages escalations lives. Phrased as a fact about the * host rather than a preference, because that is what it is: answering yes when * no such session exists leaves tier-1 escalations sitting in issue comments * that nobody drains. */ async function askOrchestratorMode(ui: WizardUi, prior: OrchestratorMode): Promise { const external = await askYesNo( ui, "orchestrator-mode", "Orchestrator session", "Do you already run your own orchestrator session for this project — a visible TUI session, say? " + "Then the daemon starts none of its own, and posts tier-1 escalations as issue comments for yours " + "to drain. Default: no, the daemon runs one" + `${prior === "external" ? " — currently external" : ""}.`, ); return external ? "external" : "embedded"; } /** * Whether workers get a code graph, and where its clones live. * * One confirm and at most one prompt, asked after the repos are known because * the answer is derived per repo. A declined answer leaves the field off every * repo, which is what keeps an existing fleet's briefs byte-identical. * * The root is validated as absolute here rather than at load time so the * operator learns immediately: a relative path would be resolved against * whichever cwd happened to read the config, and never against the directory * that was indexed. */ async function askGraphRoot( ui: WizardUi, trackerRepo: string, repoNames: string[], prior: string | undefined, ): Promise { const wanted = await askYesNo( ui, "code-graph-enabled", "Code-graph discovery", "Set up code-graph discovery for workers? Workers spend most of their turn budget finding code; " + 'a graph answers "who calls this" in one call. Conductor keeps one disposable clone per repo, ' + "pinned to the default branch purely for indexing — never your own checkout" + `${prior === undefined ? "" : `. Currently on, under ${prior}`}.`, ); if (!wanted) return undefined; return await askValid( ui, "code-graph-root", `Root for those clones — one per repo (${repoNames.join(", ")}) is created under it`, prior ?? defaultGraphRoot(trackerRepo), (v) => isAbsolute(expandHome(v)) ? undefined : `"${v}" is not an absolute path — a worker reads this from its own worktree, so a relative one names the wrong directory.`, ); } /** * Whether to render the operator's own brief, and — separately — whether an * existing one may be replaced. Two questions on purpose: that file is where a * fleet's release and reporting policy ends up, so it is never overwritten by * an operator who only meant to re-run setup. */ async function askOrchestratorBrief(ui: WizardUi, a: SetupAnswers): Promise { const path = orchestratorBriefPath(a); const wanted = await askYesNo( ui, "write-operator-brief", `Write ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME} under ${dirname(path)}?`, `Writes composed ${ORCHESTRATOR_BRIEF_NAME} (package floor, refreshed each tick) and ${POLICY_BRIEF_NAME} ` + `(Releases, Project context, Reporting, Amendments — yours to edit via the Learning loop). ` + `The conductor stops at green PRs either way.`, ); if (!wanted) return false; if (!existsSync(path)) return true; return await askYesNo( ui, "overwrite-operator-brief", `Overwrite existing ${ORCHESTRATOR_BRIEF_NAME} / ${POLICY_BRIEF_NAME}?`, `${path} already exists. Overwriting replaces the composed brief and POLICY.md scaffold — any policy you wrote is lost.`, ); } /** The project these answers would replace, so a re-run pre-fills with what is * already there instead of making the operator retype it. */ function priorProject(existing: ConductorConfig | undefined, name: string | undefined): ProjectConfig | undefined { if (existing === undefined) return undefined; if (name !== undefined) return existing.projects.find((p) => p.name === name); return existing.projects.length === 1 ? existing.projects[0] : undefined; } /** * One area's questions, over the answers everything else is carried through in. * * Every asker takes the whole answer set and returns the whole answer set with * only its own fields replaced. That is what lets the full interview fold them in * order while an amend applies exactly one, with no second spelling of either the * prompts or the defaults they pre-fill from: the value shown is always the value * that would otherwise be carried through. */ type AreaAsker = ( ui: WizardUi, a: SetupAnswers, probes: SetupProbes, discovered?: DiscoveredFacts, ) => Promise; /** * The repo-reading half of onboarding, injected rather than called directly. * * One seam, for three reasons: `--no-ai` becomes a value instead of a branch at * every call site; the wizard's own tests keep running with no model and no clone; * and a probe stays visibly optional — every asker below still works when it * returns nothing, which is the contract that lets setup survive an unreachable * peer, a private repo, or a model that answered in prose. */ export interface SetupProbes { /** Finds exact checkout/GitHub facts. `{}` seeds nothing. */ discover(target: DiscoveryTarget): Promise; /** Proposes a repo's pre-push gates by reading its CI. `[]` seeds nothing. */ gates(ui: WizardUi, target: ProbeTarget): Promise; /** * Drafts the brief's prose halves, each previewed and confirmed before it is * kept. `undefined` — declined, unavailable, `--no-ai` — keeps the shipped stub. */ prose(ui: WizardUi, a: SetupAnswers): Promise; } /** Reads each repo to propose answers. */ export const DEFAULT_PROBES: SetupProbes = { discover: (target) => discoverFacts(target), gates: (ui, target) => probeGates(ui, target), prose: (ui, a) => proseFromRepos(ui, a), }; /** `--no-ai`, and every wizard test: nothing is read, nothing is proposed. */ export const NO_PROBES: SetupProbes = { discover: async () => ({}), gates: async () => [], prose: async () => ({}), }; /** Exact facts replace only fresh defaults; saved operator answers always win. */ function seedFromDiscovery(seed: SetupAnswers, found: DiscoveredFacts): SetupAnswers { const labels = new Map((found.labels ?? []).map((name) => [name.toLowerCase(), name])); const trackerRepo = seed.trackerRepo || found.trackerRepo || ""; const targetRepos = seed.targetRepos.length > 0 || found.routingKey === undefined || found.cloneUrl === undefined ? seed.targetRepos : [ { name: found.routingKey, cloneUrl: found.cloneUrl, defaultBranch: found.defaultBranch ?? SETUP_DEFAULTS.defaultBranch, gates: [], }, ]; return { ...seed, trackerRepo, queueLabel: labels.get(seed.queueLabel.toLowerCase()) ?? seed.queueLabel, stateLabels: { inProgress: labels.get(seed.stateLabels.inProgress.toLowerCase()) ?? seed.stateLabels.inProgress, blocked: labels.get(seed.stateLabels.blocked.toLowerCase()) ?? seed.stateLabels.blocked, failed: labels.get(seed.stateLabels.failed.toLowerCase()) ?? seed.stateLabels.failed, backlog: labels.get(seed.stateLabels.backlog.toLowerCase()) ?? seed.stateLabels.backlog, }, targetRepos, policy: { merge: { ...seed.policy.merge, requiredChecks: found.requiredChecks ?? seed.policy.merge.requiredChecks, }, release: { ...seed.policy.release, artefacts: found.artefacts ?? seed.policy.release.artefacts, environments: found.environments ?? seed.policy.release.environments, }, }, }; } function showDiscovery(ui: WizardUi, found: DiscoveredFacts): void { if ((found.evidence?.length ?? 0) > 0) { ui.notify( [ "Discovered setup defaults:", ...(found.evidence ?? []).map((line) => ` ${line}`), "Every value remains editable at its prompt.", ].join("\n"), "info", ); } if ((found.skipped?.length ?? 0) > 0) { ui.notify( ["Some setup discovery was skipped; today's typed defaults remain:", ...(found.skipped ?? []).map((line) => ` ${line}`)].join("\n"), "warning", ); } } /** * The brief's two prose halves, drafted against **every** configured repo. * * All of them, in one workspace, because both answers are cross-repo by nature and * the skill this ports said so: "read, per routing repo" for the context, "per repo * that can be released" for the procedure. A draft from one repo could not say * which repo owns which concern, which repos ship together, or where the release * machinery actually lives — it would have to guess, and a guess in a file the * orchestrator re-reads every tick is an instruction it follows all week. * * One probe per section rather than per repo: `POLICY.md` has one Project context * and one Releases procedure, and N drafts would have to be merged by hand. */ async function proseFromRepos(ui: WizardUi, a: SetupAnswers): Promise { if (a.targetRepos.length === 0) return {}; const targets: ProbeTarget[] = a.targetRepos.map((r) => ({ name: r.name, cloneUrl: r.cloneUrl, defaultBranch: r.defaultBranch, })); const j = a.judgment ?? {}; const context = await probeProse(ui, "project-context", "the project context", targets, { REPOS: probeRepoMap(targets, a.routingLabelPrefix), ROADMAP: j.roadmap ?? "", }); // Only when the operator actually delegated a release: with no stated intent // there is nothing for the probe to express, and a procedure drafted from a // repo's machinery alone is the invention the template forbids. const delegated = [j.releaseWhat, j.releaseWhen, j.boundary].some( (v) => v !== undefined && v.trim().length > 0, ); const procedure = delegated ? await probeProse(ui, "release-procedure", "the release procedure", targets, { // Without this the probe cannot follow its own instruction to name repos by // routing key: the checkouts are generated `repo-N` directories, and an // arbitrary key is not recoverable from one. REPOS: probeRepoMap(targets, a.routingLabelPrefix), BOUNDARY: j.boundary ?? "", WHAT: j.releaseWhat ?? "", WHEN: j.releaseWhen ?? "", PROOF: j.releaseProof ?? "", ASK: j.releaseAsk ?? "", FORBIDDEN: j.releaseForbidden ?? "", WORTH_CUTTING: j.worthCutting ?? "", ROLLBACK_OWNER: j.rollbackOwner ?? "", }) : undefined; return { ...(context === undefined ? {} : { projectContext: context }), ...(procedure === undefined ? {} : { releaseProcedure: procedure }), }; } /** * Where work comes from and where it lands: tracker, labels, routing prefix, and * every repo an issue can be routed to, each with its gates. One area because it * is one fact — the identity of the queue — and changing any part of it without * seeing the rest is how a routing prefix stops matching its labels. */ const askTrackerAndRepos: AreaAsker = async (ui, a, probes) => { const trackerRepo = await askValid( ui, "tracker-repo", "Tracker repo (owner/repo) — where ready issues live", a.trackerRepo, (v) => (REPO_RE.test(v) ? undefined : `"${v}" is not owner/repo — e.g. acme/planning.`), ); const queueLabel = await ask( ui, "queue-label", "Queue label — the human sign-off that makes an issue claimable", a.queueLabel, ); // One confirm instead of three prompts: the namespaced defaults are right for // almost everyone, and three dialogs of Enter-to-accept is how a wizard earns // its reputation. The park label is in the same confirm, with its ownership // stated — it is not conductor-written, but it has to exist for the operator // to have the gesture (#507). const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels }; const customiseStates = await askYesNo( ui, "customise-state-labels", "State labels", `The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` + `"${stateLabels.failed}" so the tracker alone shows live state; "${stateLabels.backlog}" is ` + `yours — the conductor never writes it, but an issue carrying it is never claimed. Rename them?`, ); if (customiseStates) { stateLabels.inProgress = await ask(ui, "state-label-in-progress", "Label for a run in progress", stateLabels.inProgress); stateLabels.blocked = await ask(ui, "state-label-blocked", "Label for a run parked on a human", stateLabels.blocked); stateLabels.failed = await ask(ui, "state-label-failed", "Label for a run that gave up", stateLabels.failed); stateLabels.backlog = await ask( ui, "state-label-backlog", "Label the operator applies to park an issue (the conductor never writes it)", stateLabels.backlog, ); } const routingLabelPrefix = await ask( ui, "routing-label-prefix", "Routing label prefix — an issue picks its checkout with ", a.routingLabelPrefix, ); const targetRepos: SetupAnswers["targetRepos"] = []; for (let i = 0; ; i++) { const seed = a.targetRepos[i]; const name = await askValid( ui, `routing-key.${i + 1}`, `Routing key for repo ${i + 1} — the "${routingLabelPrefix}" label an issue carries`, seed?.name ?? "", (v) => (v.length > 0 ? undefined : "A routing key is required, or no issue can reach this repo."), ); const cloneUrl = await askValid( ui, `clone-url.${name}`, `Clone URL for ${routingLabelPrefix}${name}`, seed?.cloneUrl ?? "", (v) => (v.length > 0 ? undefined : "A clone URL is required — the daemon mirrors it before every run."), ); const defaultBranch = await ask( ui, `default-branch.${name}`, `Default branch for ${name} — worktrees are cut from it and PRs target it`, seed?.defaultBranch ?? SETUP_DEFAULTS.defaultBranch, ); // Read before the prompt, so what the probe found is on screen while the // operator reads the default. Skipped when this repo already has gates. const existingGates = seed?.gates ?? []; const probed = existingGates.length > 0 ? [] : await probes.gates(ui, { name, cloneUrl, defaultBranch }); targetRepos.push({ name, cloneUrl, defaultBranch, gates: await askGates(ui, name, existingGates, probed) }); const more = await askYesNo( ui, `another-repo.${i + 1}`, "Another repo?", `${targetRepos.map((r) => r.name).join(", ")} configured. Add another checkout this project routes to?`, ); if (!more) break; } return { ...a, trackerRepo, queueLabel, stateLabels, routingLabelPrefix, targetRepos }; }; /** * The gates alone, repo by repo, with nothing else asked. * * The area that earns amend mode: a CI command changes far more often than a * clone URL does, and re-typing four repos to correct one lint invocation is the * reason an operator edits config.json by hand instead. */ const askGatesOnly: AreaAsker = async (ui, a, probes) => { if (a.targetRepos.length === 0) { ui.notify("No repos are configured yet — amend \"tracker & repos\" first.", "warning"); return a; } const targetRepos: SetupAnswers["targetRepos"] = []; for (const r of a.targetRepos) { // Re-read even though gates exist: this is the area an operator opens *because* // CI changed, so a fresh proposal is the whole reason they came. Their current // gates stay the default; the proposal is shown beside it. const probed = await probes.gates(ui, { name: r.name, cloneUrl: r.cloneUrl, defaultBranch: r.defaultBranch }); targetRepos.push({ ...r, gates: await askGates(ui, r.name, r.gates, probed) }); } return { ...a, targetRepos }; }; /** * Whether workers get a code graph, and where its clones live. Asked after the * repos in the full interview because the answer is derived per repo. */ const askGraph: AreaAsker = async (ui, a) => { const graphRoot = await askGraphRoot( ui, a.trackerRepo, a.targetRepos.map((r) => r.name), a.graphRoot, ); const next: SetupAnswers = { ...a }; // Deleted rather than set to `undefined`: the absence of the key is what keeps // a project that declines graphs identical to one written before they existed. if (graphRoot === undefined) delete next.graphRoot; else next.graphRoot = graphRoot; return next; }; /** The hard ceilings. One confirm first, because the shipped defaults are the * answer for anyone who has not measured their own runners. */ const askCaps: AreaAsker = async (ui, a) => { const caps: Partial = { ...a.caps }; const spendLabel = DEFAULT_CAPS.dailySpendUsd === null ? "no spend cap" : `$${DEFAULT_CAPS.dailySpendUsd}/day`; // Workers are in-process omp sessions inside the daemon PID. On a host under // 16 GiB the measured shape (2 workers + orchestrator) peaks at 3–4 GB and // swaps on a 7.6 GB shared VPS (#51) — setup therefore defaults to 1 there. const workersDefault = caps.maxConcurrentWorkers ?? recommendedMaxWorkers(hostRamBytes()); const smallHostNote = workersDefault < DEFAULT_CAPS.maxConcurrentWorkers ? ` This host looks under 16 GiB RAM, so the worker default is ${workersDefault} instead of ${DEFAULT_CAPS.maxConcurrentWorkers}.` : ""; const tuneCaps = await askYesNo( ui, "tune-caps", "Caps", `Defaults: ${workersDefault} workers, ` + `${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} base / ` + `${DEFAULT_CAPS.workerMaxTurnsCeiling} max turns and ` + `${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` + `${DEFAULT_CAPS.maxAttemptsPerIssue} failed attempts and ` + `${DEFAULT_CAPS.maxContinuationsPerIssue} operational continuations per issue.${smallHostNote} Change them?`, ); if (!tuneCaps) { if ( caps.maxConcurrentWorkers === undefined && workersDefault !== DEFAULT_CAPS.maxConcurrentWorkers ) { caps.maxConcurrentWorkers = workersDefault; } return { ...a, caps }; } // Spelled out rather than looped: adding a cap should fail to compile here, // not silently go unasked. caps.maxConcurrentWorkers = await askNumber( ui, "max-concurrent-workers", "Max concurrent workers", workersDefault, ); caps.dailySpendUsd = await askSpendCap( ui, "daily-spend-usd", "Spend ceiling per rolling day (USD) — blank = no spend cap", caps.dailySpendUsd !== undefined ? caps.dailySpendUsd : DEFAULT_CAPS.dailySpendUsd, ); const workerMaxTurns = await askNumber( ui, "worker-max-turns", "Turn ceiling per worker", caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns, ); caps.workerMaxTurns = workerMaxTurns; const turnCeilingFallback = Math.max( workerMaxTurns, caps.workerMaxTurnsCeiling ?? Math.min(workerMaxTurns * 2, Number.MAX_SAFE_INTEGER), ); const workerMaxTurnsCeiling = await askNumber( ui, "worker-max-turns-ceiling", "Maximum turn ceiling for one issue", turnCeilingFallback, ); if (workerMaxTurnsCeiling < workerMaxTurns) { ui.notify( `Maximum turn ceiling ${workerMaxTurnsCeiling} cannot be below the ` + `${workerMaxTurns}-turn worker base — keeping ${turnCeilingFallback}.`, "warning", ); caps.workerMaxTurnsCeiling = turnCeilingFallback; } else { caps.workerMaxTurnsCeiling = workerMaxTurnsCeiling; } caps.workerWallClockMs = await askNumber( ui, "worker-wall-clock-ms", "Wall-clock ceiling per worker (ms)", caps.workerWallClockMs ?? DEFAULT_CAPS.workerWallClockMs, ); caps.maxAttemptsPerIssue = await askNumber( ui, "max-attempts-per-issue", "Failed implementation attempts per issue before escalation", caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue, ); caps.maxContinuationsPerIssue = await askNumber( ui, "max-continuations-per-issue", "Operational continuations per issue before escalation", caps.maxContinuationsPerIssue ?? DEFAULT_CAPS.maxContinuationsPerIssue, ); return { ...a, caps }; }; /** Outside the caps block: a model is not a ceiling, and an operator who left * the caps alone may still want workers on a cheaper model. */ const askWorkerModel: AreaAsker = async (ui, a) => { const answered = await ask( ui, "worker-model", "Worker model pattern (blank = harness default)", a.workerModel ?? "", ); const next: SetupAnswers = { ...a }; if (answered.trim().length === 0) delete next.workerModel; else next.workerModel = answered.trim(); return next; }; /** The free-form omp settings overlay (#537): one YAML answer, omp's own * vocabulary rather than a list this package can offer. The seed is the * current map rendered as JSON — a YAML 1.2 subset — so a re-run that Enters * re-affirms it on one line; a blank answer clears the overlay; anything that * is not a YAML mapping re-asks, because an answer the dispatcher would * silently drop is exactly the input this wizard exists to catch. */ const askOmpSettings: AreaAsker = async (ui, a) => { const next: SetupAnswers = { ...a }; const seed = a.ompSettings === undefined ? "" : JSON.stringify(a.ompSettings); const answered = await askValid( ui, "omp-settings", "Omp settings overlay for workers (YAML, blank = none — omp's schema, not conductor's)", seed, (value) => { if (value.trim().length === 0) return undefined; const parsed = parseOmpSettingsYaml(value); return parsed.ok ? undefined : parsed.problem; }, ); if (answered.trim().length === 0) { delete next.ompSettings; return next; } const parsed = parseOmpSettingsYaml(answered); // askValid already proved the shape on this exact string; a parse failure // here is an internal invariant, never a silently dropped overlay. if (!parsed.ok) throw new Error(`unreachable: askValid accepted unparseable omp settings: ${parsed.problem}`); next.ompSettings = parsed.value; return next; }; /** The three ownership questions, then the mechanical gate one shape at a time: * together they are what decides what an unattended fleet may do unasked. */ const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => { const authority = await askAuthority(ui, a.authority); // Declining release authority makes the five per-shape grants moot — they // exist to scope an authority that was just refused, and asking them after // "humans release" teaches the operator that answers do not matter (#368). // // Humans-only authority clears any stale per-shape grant to the fail-closed // baseline rather than offering an individual-tool path: `releaseVerb` // refuses before a shape grant is ever read (see `verbs/server`), so a shape // granted under humans-only authority is a dead prompt that says the opposite // of what the operator answered. Clearing keeps the tick tripwire, which // reads the grant map independently of authority, from enforcing a shape the // operator just revoked. const releaseGrants = authority.release === "human" ? { ...DENIED_RELEASE_GRANTS } : await askReleaseGrants(ui, a.releaseGrants); // Asked here because it is the same decision one layer down: the grants say // who may act, and these say where that permission stops. Amending authority // therefore re-asks the boundary, which is the point — a grant widened without // restating the boundary is how a delegated release loses its end. const judgment = await askJudgment(ui, releaseGrants, a.judgment ?? {}, discovered?.roadmaps); return { ...a, authority, releaseGrants, judgment }; }; /** What a merge and a release must satisfy. Asked straight after the grants: * who may act, then under what conditions. The release half is only asked * when the orchestrator cuts releases — with humans releasing it has no gates * to configure, so it would be a dead prompt (#368). The arming proof is * asked after the preconditions: it gates `arm`, not merge or release, but it * is the same kind of declared policy, so it rides the same area (#613). * * The `claim-only` consequence is stated in the option's own description so an * operator chooses it rather than discovering it: `arm` is already privileged, * and `claim-only` means anything that can invoke it can start dispatch once * the live claim and poller pass. */ /** * How many review rounds one PR lifecycle may be returned at most — the hard * bound against endless polishing (#678). Validated in the dialog against the * same integer range the loader validates against, so an out-of-range answer * is re-asked rather than written for the daemon to reject. The consequence of * the ceiling is stated in the title: at it, the PR is left open and escalated * once, never returned again. */ async function askReviewRounds(ui: WizardUi, current: number): Promise { // Bounded like askValid above: a dialog that cannot be escaped is worse than // one that gives up and leaves the config alone. The `--answers` path // matters here specifically — answersUi is a map, not a consumer, so every // retry reads the same value and an out-of-range file used to recurse // forever instead of failing closed (#678). for (let attempt = 0; attempt < 3; attempt++) { const raw = await ask( ui, "review-max-rounds", `Review rounds per PR lifecycle — an integer ${REVIEW_MAX_ROUNDS_MIN} to ${REVIEW_MAX_ROUNDS_MAX}; ` + `at the ceiling the PR is left open and escalated once`, String(current), ); const value = Number(raw); if (Number.isInteger(value) && value >= REVIEW_MAX_ROUNDS_MIN && value <= REVIEW_MAX_ROUNDS_MAX) { return value; } ui.notify( `Review rounds must be an integer between ${REVIEW_MAX_ROUNDS_MIN} and ${REVIEW_MAX_ROUNDS_MAX} ` + `— shown the current value again.`, "warning", ); } throw new Cancelled(); } /** The answer-file key the adjudicator question is recorded under. */ const REVIEW_ADJUDICATOR_KEY = "review-adjudicator-role"; /** * Whether one role is a legitimate adjudicator answer on this host right now: * it must be a single loadable role token (`REVIEW_ADJUDICATOR_RE`) AND a * `modelRoles` key of the daemon's global settings or of the project's omp * overlay — or the deterministic default, which setup must always be able to * write back. Anything else — including a project's current role that was * removed from both surfaces, or a malformed `modelRoles` key the loader * would refuse — is an unavailable role and must not be accepted, whatever * the config says (#875). */ function isAdjudicatorAvailable(live: ConfiguredOmpRoles, overlay: readonly string[], role: string): boolean { if (!REVIEW_ADJUDICATOR_RE.test(role)) return false; return role === DEFAULT_REVIEW_ADJUDICATOR_ROLE || live.global.includes(role) || overlay.includes(role); } /** * The adjudicator roles the dialog accepts, derived from the live OMP role * configuration — the daemon's global settings, then the project's omp * overlay — with the current answer first and the deterministic default * offered exactly once (#875). The order is the contract: an amend's Enter * re-affirms the current value, and the shipped default stays expressible for * a project whose operator never answered. * * Every role is filtered through the same token grammar the loader enforces: * a `modelRoles` key the config gate would reject must never be offered (or * accepted), because persisting it would write a policy the next loadConfig * refuses and stop the fleet. The current answer leads the list only while it * is still available: a role removed from both the global settings and the * overlay must not be accepted back into the config by a bare Enter — the * caller warns that it cannot be kept, and the accepted set excludes it, so * Entering it refuses exactly like typing any other unavailable role. */ function adjudicatorChoices(live: ConfiguredOmpRoles, overlay: readonly string[], current: string): string[] { const choices: string[] = []; const seen = new Set(); const first = isAdjudicatorAvailable(live, overlay, current) ? [current] : []; for (const role of [...first, ...live.global, ...overlay, DEFAULT_REVIEW_ADJUDICATOR_ROLE]) { if (REVIEW_ADJUDICATOR_RE.test(role) && !seen.has(role)) { seen.add(role); choices.push(role); } } return choices; } /** One role row's description: what makes this role available and why. */ function adjudicatorDescription( role: string, live: ConfiguredOmpRoles, overlay: readonly string[], ): string | undefined { if (live.global.includes(role) || overlay.includes(role)) { return role === DEFAULT_REVIEW_ADJUDICATOR_ROLE ? "configured OMP model role — the deterministic default" : "configured OMP model role"; } if (role === DEFAULT_REVIEW_ADJUDICATOR_ROLE) { return "the deterministic default every project loads until answered"; } return undefined; } /** * Which OMP model role runs a PR's terminal review-ceiling adjudication * (#875). The offered roles are derived from the live OMP role configuration * (the daemon account's global settings and the project's overlay, never a * conductor-owned registry), the prompt opens on the current answer, and any * answer that is not one of those configured roles is refused with the place * to configure it named. * * Driven through `askValid`, not a select, on purpose: a select surface can * only ever return an offered label, so the unconfigured-value refusal would * be unreachable on the real UIs and the answer-file path would fail with a * generic "must be a listed label" before this guidance ran. A validated text * answer gives every surface — terminal, scripted and `--answers` — the same * bounded three tries and the same actionable error naming the settings file. */ async function askReviewAdjudicator( ui: WizardUi, current: string, live: ConfiguredOmpRoles, overlay: readonly string[], ): Promise { const choices = adjudicatorChoices(live, overlay, current); if (!live.decoded) { ui.notify( `The daemon's OMP settings at ${live.path} could not be read — only the project's overlay roles and the default are offered.`, "warning", ); } // A project whose configured role was removed from both surfaces must not be // allowed to keep it: the warning names the role and the way back, and the // accepted list above already excludes it — Entering it now refuses exactly // like typing any other unavailable role (#875). if (!isAdjudicatorAvailable(live, overlay, current)) { ui.notify( `"${current}" is this project's current adjudicator but not a currently configured OMP model role — it cannot be kept. ` + `Add "${current}" back as a modelRoles key to ${live.path} or this project's omp settings overlay, ` + `or type one of the available roles below.`, "warning", ); } ui.notify( choices .map((role) => { const described = adjudicatorDescription(role, live, overlay); return `${role}${role === current ? " (current)" : ""}${described === undefined ? "" : ` — ${described}`}`; }) .join("\n"), "info", ); return askValid( ui, REVIEW_ADJUDICATOR_KEY, "Adjudicator for a PR at the review ceiling — one configured OMP model role, not a model", current, (value) => { if (choices.some((choice) => choice === value)) return undefined; return ( `"${value}" is not one of the OMP model roles available here — add it as a modelRoles key to the daemon's OMP settings ` + `(${live.path}) or this project's omp settings overlay, or type an offered role. ` + `A role is a single token like "task" — never a provider or model.` ); }, ); } /** * How green PRs are reviewed (#678), asked with the merge preconditions and * the arming proof: they are the same kind of declared policy — a typed, * mechanical boundary on what may happen to a PR — so they ride the same area. * * The three levels and their bars are shown before the select, with the * recommended level marked, so an operator chooses between named thresholds * rather than between three words. The select cursor opens on the current * answer (the configured level on a re-run, the recommended default on a * first run); the rounds are a validated integer within the same range the * loader enforces; the adjudicator role is offered from the live OMP role * configuration (#875) — the options and their availability come from the * daemon's OMP settings and the project's overlay, never from a conductor * model registry. */ async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise { ui.notify( REVIEW_STRICTNESS.map((level) => `${level}${level === DEFAULT_REVIEW_STRICTNESS ? " (recommended)" : ""} — ${REVIEW_STRICTNESS_CHOICES[level]}`).join( "\n", ), "info", ); const live = readConfiguredOmpRoles(); return { strictness: await askLiteral( ui, "review-strictness", "Review strictness for green PRs", REVIEW_STRICTNESS, REVIEW_STRICTNESS_CHOICES, a.review.strictness, ), maxRounds: await askReviewRounds(ui, a.review.maxRounds), adjudicator: await askReviewAdjudicator(ui, a.review.adjudicator, live, modelRolesIn(a.ompSettings)), }; } const askPolicy: AreaAsker = async (ui, a) => ({ ...a, policy: await askPolicyPreconditions(ui, a.policy, a.authority.release), armProof: await askLiteral( ui, "arm-proof", "Arming — how should `arm` prove a human approved dispatch?", ARM_PROOFS, ARM_PROOF_CHOICES, a.armProof, ), review: await askReviewPolicy(ui, a), }); /** How a stuck run reaches a human, and who triages it when it does. */ const askEscalation: AreaAsker = async (ui, a) => { const telegram = detectTelegram(); let telegramChatId = a.telegramChatId; if (telegram.available && telegram.hasToken) { if (telegramChatId === undefined && telegram.pairedOwnerId !== undefined) { const usePaired = await askYesNo( ui, "tier2-use-paired-chat", "Tier-2 escalations", `omp-telegram is paired with chat ${telegram.pairedOwnerId}. Page it when a run is stuck?`, ); if (usePaired) telegramChatId = telegram.pairedOwnerId; } else { const answered = await ask( ui, "tier2-chat-id", "Telegram chat id for tier-2 escalations (blank for none)", telegramChatId ?? "", ); telegramChatId = answered.length > 0 ? answered : undefined; } } else { // Not an error: tier 2 degrades to a comment, which is the documented fallback. ui.notify( `No usable omp-telegram install at ${telegram.stateDir} — tier-2 escalations will comment on the issue.`, "info", ); } const telegramTopicId = telegramChatId === undefined ? undefined : await askTelegramTopicId(ui, telegram.stateDir, a.telegramTopicId); const fallbackToIssueComment = await askYesNo( ui, "escalation-fallback", "Escalation fallback", "Also comment on the issue when a run escalates? Recommended: a chat message you miss is a run nobody sees.", ); const orchestratorMode = await askOrchestratorMode(ui, a.orchestratorMode); const next: SetupAnswers = { ...a, fallbackToIssueComment, orchestratorMode }; if (telegramChatId === undefined) delete next.telegramChatId; else next.telegramChatId = telegramChatId; if (telegramTopicId === undefined) delete next.telegramTopicId; else next.telegramTopicId = telegramTopicId; return next; }; /** * Forum topic for tier-2 pages. Claimed threads from omp-telegram's * `threads.json` are offered when readable; a missing file is silent and the * operator can still type an id or keep flat chat (#318). A registry that is * *present but unreadable* is the opposite: the bridge is in a state the * wizard cannot read, so the manual id would be a guess against unverifiable * state — surface the problem before the fallback instead of silently offering * either (#626). */ async function askTelegramTopicId( ui: WizardUi, stateDir: string, prior: number | undefined, ): Promise { const result = claimedTelegramTopics(stateDir); if (result.kind === "unavailable") { ui.notify( `Cannot offer claimed topics: omp-telegram's claim registry is unreadable (${result.problem}) — check the bridge state before typing a topic id, or keep flat chat`, "warning", ); } const claimed = result.kind === "ok" ? result.claims : []; const manual = "Enter thread id manually"; const none = "None — flat chat (0.13 behaviour)"; if (claimed.length > 0) { const options = [ ...claimed.map((t) => ({ label: `${t.name} — ${t.threadId}`, // Two panes under one state directory claim the same title, so the // herdr space is the only thing that tells the operator which is which. description: t.workspaceLabel === undefined || t.workspaceLabel === t.name ? `message_thread_id ${t.threadId}` : `herdr space ${t.workspaceLabel} · message_thread_id ${t.threadId}`, })), { label: manual, description: "type a numeric forum topic id" }, { label: none, description: "send to the chat root, not a topic" }, ]; const priorIdx = prior === undefined ? -1 : claimed.findIndex((t) => t.threadId === prior); const picked = await ui.select("Telegram forum topic for tier-2 pages", options, { key: "tier2-topic", initialIndex: priorIdx >= 0 ? priorIdx : claimed.length + 1, }); if (picked === undefined) throw new Cancelled(); if (picked === none) return undefined; if (picked !== manual) { const hit = claimed.find((t) => `${t.name} — ${t.threadId}` === picked); if (hit !== undefined) return hit.threadId; } } const answered = await ask( ui, "tier2-topic-id", "Telegram forum topic id (blank for flat chat)", prior !== undefined ? String(prior) : "", ); if (answered.length === 0) return undefined; const n = Number(answered); if (!Number.isFinite(n) || !Number.isSafeInteger(n)) { ui.notify(`"${answered}" is not an integer topic id — using flat chat.`, "warning"); return undefined; } return n; } /** How loud the orchestrator is and when the operator permits interruptions. */ const askReporting: AreaAsker = async (ui, a) => { const reportScope = await askReportScope(ui, a.reportScope); const continuous = "Continuous (24-hour interrupts)"; const weekly = "Weekly availability window"; const mode = await ui.select( "Operator availability", [ { label: continuous, description: "preserve legacy behavior: configured interrupt categories may page at any hour", }, { label: weekly, description: "hold non-bypass interruptions outside selected local working hours", }, ], { key: "operator-availability", initialIndex: a.availability === undefined ? 0 : 1 }, ); if (mode === undefined) throw new Cancelled(); const next: SetupAnswers = { ...a, reportScope }; delete next.preservedReporting; delete next.availability; const applyDigestSchedule = (raw: string): SetupAnswers => { const updated = { ...next }; delete updated.dailyDigestAt; const value = raw.trim().toLowerCase(); if (value === "per-tick") { updated.digestCadence = "per-tick"; } else if (value === "off" || value === "disabled" || value === "none") { updated.digestCadence = "none"; } else if (value === "model-timed" || value === "daily") { updated.digestCadence = "daily"; } else { updated.digestCadence = "daily"; updated.dailyDigestAt = raw; } return updated; }; const askDigestSchedule = async (fallback: string): Promise => { const shown = a.digestCadence === "per-tick" ? "per-tick" : a.digestCadence === "none" ? "off" : a.dailyDigestAt ?? (a.digestCadence === "daily" ? "model-timed" : fallback); const schedule = await askValid( ui, "digest-schedule", 'Daily rollup time in that timezone / digest cadence ("per-tick", "model-timed", "off", or 24h HH:MM)', shown, (value) => ["per-tick", "model-timed", "daily", "none", "off", "disabled"].includes( value.toLowerCase(), ) || /^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? undefined : 'Use "per-tick", "model-timed", "off", or a 24h HH:MM time.', ); return applyDigestSchedule(schedule); }; if (mode !== weekly) { if (mode !== continuous) { ui.notify(`Unrecognised availability choice "${mode}" — keeping 24-hour interrupts.`, "warning"); } if (reportScope !== "quiet") return next; return await askDigestSchedule("model-timed"); } const defaultZone = a.reportingTimezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC"; const reportingTimezone = await askValid( ui, "reporting-timezone", "Operator timezone (IANA, for example Europe/London)", defaultZone, (value) => { try { new Intl.DateTimeFormat("en", { timeZone: value }).format(); return undefined; } catch { return `"${value}" is not a known IANA timezone.`; } }, ); const daysText = await askValid( ui, "working-weekdays", "Working weekdays (comma-separated: mon,tue,wed,thu,fri,sat,sun)", a.availability?.days.join(",") ?? "mon,tue,wed,thu,fri", (value) => { const days = value.split(",").map((day) => day.trim().toLowerCase()); if (days.length === 0 || days.some((day) => !WEEKDAYS.includes(day as Weekday))) { return `Use only: ${WEEKDAYS.join(",")}.`; } return new Set(days).size === days.length ? undefined : "List each weekday only once."; }, ); const days = daysText.split(",").map((day) => day.trim().toLowerCase() as Weekday); const start = await askValid( ui, "availability-start", "Availability starts (24h HH:MM)", a.availability?.start ?? "09:00", (value) => (/^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? undefined : "Use 24h HH:MM."), ); const end = await askValid( ui, "availability-end", "Availability ends (24h HH:MM)", a.availability?.end ?? "17:00", (value) => !/^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? "Use 24h HH:MM." : value === start ? "Start and end must differ." : undefined, ); const bypassText = await askValid( ui, "quiet-hours-bypass", `Quiet-hours bypass categories (comma-separated; "none" = none; choices: ${INTERRUPT_CATEGORIES.join(",")})`, a.availability === undefined ? "fleet-stopped" : a.availability.bypass.join(",") || "none", (value) => { if (value.toLowerCase() === "none") return undefined; const categories = value.split(",").map((category) => category.trim().toLowerCase()); if (categories.some((category) => !INTERRUPT_CATEGORIES.includes(category as InterruptCategory))) { return `Use only: ${INTERRUPT_CATEGORIES.join(",")}, or "none".`; } return new Set(categories).size === categories.length ? undefined : "List each category only once."; }, ); const bypass = bypassText.toLowerCase() === "none" ? [] : bypassText.split(",").map((category) => category.trim().toLowerCase() as InterruptCategory); const scheduled = await askDigestSchedule(end); return { ...scheduled, reportingTimezone, availability: { days, start, end, bypass }, }; }; /** The operator's own brief. Asked last in the full interview, because the * question quotes the path the rest of the answers derive. */ const askBrief: AreaAsker = async (ui, a) => ({ ...a, writeOrchestratorBrief: await askOrchestratorBrief(ui, a) }); /** * One dialog sequence per amend area, keyed so a new area cannot be added to * {@link AMEND_AREA_IDS} without one. */ const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = { tracker: askTrackerAndRepos, gates: askGatesOnly, // The two per-worker knobs the full interview separates with the authority // grants; an amend has no reason to put anything between them. caps: async (ui, a, probes) => await askWorkerModel(ui, await askCaps(ui, a, probes), probes), "omp-settings": askOmpSettings, "code-graph": askGraph, authority: askAuthorityArea, policy: askPolicy, escalation: askEscalation, reporting: askReporting, // `askBrief` alone would be a trap. The floor brief and the unfilled stub both // send an operator here to *fill in* Releases and Project context, and judgment // is deliberately not persisted — so an area that asked only "overwrite it?" // would rewrite POLICY.md from an empty judgment and replace a filled file with // the very stubs they came to remove. The grants are already on disk, so the // boundary questions are asked against the authority this project really has. brief: async (ui, a, probes) => await askBrief(ui, { ...a, judgment: await askJudgment(ui, a.releaseGrants, a.judgment ?? {}) }, probes), }; /** * The full interview's areas, in the exact order `collectAnswers` walks them. * * This array is the single source of both the questions and their progress * marker: the "m" in every `Area n of m` line is this list's `.length`, never a * separate constant, so an area a branch predicate prunes takes its row and its * slot in the denominator with it — the count cannot drift from the iterable. * * The order is load-bearing and each step documents its own rationale: the * graph sits straight after the repos because the answer is derived per repo; * the worker model sits after the policy block because the boundary is an * authority-shaped decision, not a runtime knob; the brief is last because its * write question needs the real path the rest of the answers derive. */ const INTERVIEW_AREAS: readonly { label: string; asker: AreaAsker }[] = [ { label: "tracker & repos", asker: askTrackerAndRepos }, { label: "code graph", asker: askGraph }, { label: "caps", asker: askCaps }, { label: "authority & release grants", asker: askAuthorityArea }, { label: "merge & release preconditions", asker: askPolicy }, { label: "worker model", asker: askWorkerModel }, // The omp settings overlay rides next to the worker model: both are // per-worker runtime knobs, and neither is a ceiling the caps block owns. { label: "omp settings overlay", asker: askOmpSettings }, { label: "escalation & triage", asker: askEscalation }, { label: "reporting", asker: askReporting }, { label: "orchestrator brief", asker: askBrief }, ]; /** The two ways to answer the first question a configured project gets. Labels, * because the harness's select resolves to the label it displayed. */ const AMEND_ONE = "Change one area"; const REINTERVIEW = "Walk every question again"; /** Third chooser row when a config already exists: start a full interview for a * brand-new neighbour rather than amending the one that is already there. */ const ADD_PROJECT = "Add another project"; /** * The consent gate's four choices, in the order the contract names them: the * old yes/no "apply?" is now a menu whose decline loops back instead of * dropping the interview (#417). The plan is recomputed from the answers * (including the predictive queue preview) before every row is offered again. */ const REVIEW_APPLY = "Apply"; const REVIEW_EDIT = "Edit an area"; const REVIEW_REVIEW = "Review the plan"; const REVIEW_EXIT = "Exit"; /** A bare Enter lands on Review — the one row with no effect, so neither * applying nor discarding can happen by accident. */ const REVIEW_CHOICES: { label: string; description: string }[] = [ { label: REVIEW_APPLY, description: "run the apply sequence once: labels, config, briefs, staged host files, paused-daemon smoke", }, { label: REVIEW_EDIT, description: "re-ask one interview area; the plan re-derives from the new answers", }, { label: REVIEW_REVIEW, description: "show the current plan again — nothing applies" }, { label: REVIEW_EXIT, description: "discard these answers; nothing is written or changed" }, ]; /** Outcome of the first chooser on a re-run. */ type AmendChoice = | { kind: "area"; area: AmendAreaId } | { kind: "reinterview" } | { kind: "add-project" }; /** * The first question a re-run asks, and the reason amend mode exists: adding one * key should not cost twenty prompts. Also the entry for adding a neighbour * project without leaving the wizard (#319). * * Returns the area to amend, a full re-interview of the named project, or the * add-a-project path. Only asked when a project is already configured — a first * run has nothing to amend and is never shown this. */ async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig, defaults: Caps = DEFAULT_CAPS): Promise { const mode = await ui.select( `"${prior.name}" is already configured — what would you like to do?`, [ { label: AMEND_ONE, description: "asks one area's questions; every other answer is carried through from the saved config", }, { label: REINTERVIEW, description: "the full interview, every prompt pre-filled with what is configured now", }, { label: ADD_PROJECT, description: "full interview for a new project; existing projects stay as they are", }, ], { key: `existing-project-action.${prior.name}`, initialIndex: 0 }, ); if (mode === undefined) throw new Cancelled(); if (mode === ADD_PROJECT) return { kind: "add-project" }; if (mode !== AMEND_ONE) { // Either the operator chose the full interview, or the dialog answered with // a label we never offered. Both land on today's behaviour, which is the one // that cannot silently skip a question. if (mode !== REINTERVIEW) ui.notify(`Unrecognised choice "${mode}" — asking everything.`, "warning"); return { kind: "reinterview" }; } const choices = amendChoices(prior, defaults); const picked = await ui.select( "Which area? Each row shows what it says now", choices.map((c) => ({ label: c.label, description: c.description })), { key: `amend-area.${prior.name}`, initialIndex: 0 }, ); if (picked === undefined) throw new Cancelled(); const chosen = choices.find((c) => c.label === picked); if (chosen === undefined) { // Guessing an area here would ask the wrong questions and carry the rest // through as if they had been reviewed. Abandoning changes nothing. ui.notify(`Unrecognised choice "${picked}" — nothing was changed.`, "warning"); throw new Cancelled(); } return { kind: "area", area: chosen.id }; } /** * The conversation. Reads only — every answer is collected before anything is * checked against GitHub, and long before anything is written. * * Seeded from one answers object rather than pre-filling each prompt from * `prior?.field ?? default`: that is the same carry-through an amend relies on, * so the two flows cannot disagree about what an unanswered field is. */ async function collectAnswers( ui: WizardUi, prior: ProjectConfig | undefined, projectArg: string | undefined, probes: SetupProbes, opts: { added?: boolean } = {}, ): Promise { // Discovery is a fresh-interview default only. Re-runs start from the saved // project, so an operator-edited value is never re-guessed. const discovered = prior === undefined ? await probes.discover({ cwd: process.cwd() }) : {}; showDiscovery(ui, discovered); // Added projects seed under projects//; a first install and a re-interview // of an existing project keep the flat (or already-on-disk) roots. const baseSeed = prior === undefined ? defaultAnswers(projectArg ?? "", { added: opts.added === true }) : answersFromProject(prior); const seed = prior === undefined ? seedFromDiscovery(baseSeed, discovered) : baseSeed; const projectName = await askValid( ui, "project-name", "Project name", projectArg ?? seed.projectName, (v) => (v.length > 0 ? undefined : "A name is required — it is how `omp-conductor status --project ` finds this project."), ); // When the operator renames mid-interview on an add, re-seed the scoped roots // so they track the final name rather than the empty/CLI placeholder. let a: SetupAnswers = { ...seed, projectName }; if (opts.added === true && prior === undefined) { const scoped = defaultAnswers(projectName, { added: true }); a = { ...a, workspaceRoot: scoped.workspaceRoot, mirrorRoot: scoped.mirrorRoot, }; } // Walk the ordered area list once; the marker's denominator is the list's // length, so an area a branch prunes drops its row and its slot together. const areas = INTERVIEW_AREAS; for (const [index, area] of areas.entries()) { ui.notify(`Area ${index + 1} of ${areas.length}: ${area.label}`, "info"); a = await area.asker(ui, a, probes, discovered); } return a; } /** The dry run, rendered. Same routing code the loop uses, so this is what the * next tick would actually do — not a description of it. */ function formatPreview(p: QueuePreview): string[] { const lines = [ `state ${p.paused ? "paused" : "armed"}`, p.ready.length === 0 ? "Would pick up: nothing — the queue is empty." : `Would pick up ${p.ready.length} issue(s), caps permitting:`, ]; for (const r of p.ready) lines.push(` #${r.number} → ${r.repo} ${r.branch} ${r.title}`); if (p.unroutable.length > 0) { lines.push("", `Cannot route ${p.unroutable.length} issue(s) — these escalate instead of running:`); for (const u of p.unroutable) { lines.push(` #${u.number} ${u.reason} [${u.labels.join(", ") || "no labels"}] ${u.title}`); } } return lines; } /** What the whole conversation produced: the answers, and which area an amend * narrowed it to. `amend` absent means every question was asked. */ export interface CollectedSetup { answers: SetupAnswers; amend?: { area: AmendAreaId; before: ProjectConfig }; /** True when this run is adding a neighbour project (or first-run-for-name). */ added?: boolean; } /** * The consent gate's re-derivable view of the *current* answers: everything the * plan shows and the apply tail writes. The review loop rebuilds it after every * edit — config, project, runtime plan, caps, and the predictive queue preview * all re-derive from the answers, so the operator can only approve a diff that * matches them (#417). */ interface DerivedPlan { nextConfig: ConductorConfig; project: ProjectConfig; runtime: HostRuntimePlan; /** Re-derived from the *cached* label listing: one `gh` read per run. */ labels: LabelPlan[]; toCreate: string[]; totalWorkers: number; overcommit: string | undefined; queuePreview: string[]; } export async function ensureSetupArm( projectName: string, arm: typeof armTicks = armTicks, /** * Where the ceremony's one line goes. Setup holds dispatch under its own * fence across this call, so the send receipt has to be visible: an operator * who sees nothing cannot tell a sent challenge from a stalled wizard (#861). */ progress?: (line: string) => void, ): Promise { const result = await arm(projectName, progress === undefined ? {} : { progress }); // Arming is two steps now: the wizard can send the challenge, but only the // operator's reply arms the heartbeat, and that reply lands in the console // session rather than here. Reporting "armed" off a sent challenge would be a // lie the operator only discovers when no tick ever fires. if (result.outcome === "challenge-sent") { return ( `arming challenge sent to owner ${result.owner}, valid for ${result.validFor} — NOT yet armed. ` + `Finish it with: ${result.followUp}` ); } const armed = result.armed[0]!; return armed.alreadyArmed ? `existing heartbeat arm revalidated for owner ${result.owner} at ${armed.path}` : `heartbeat armed for owner ${result.owner} at ${armed.path}`; } /** * The whole conversation, from the amend question to the last prompt, and not one * byte further: no `gh`, no dry run, nothing written. * * Exported at exactly that seam so a test can script the dialogs and pin what a * first run asks and what an amend refuses to ask — the two properties amend mode * is judged on — on a host with no `gh` and no config. */ export async function collectSetup( ui: WizardUi, existing: ConductorConfig | undefined, projectArg: string | undefined, areaArg: AmendAreaId | undefined, probes: SetupProbes, ): Promise { // Only a project that is already configured can be amended. A first run, or a // name this config has never seen, goes straight into the full interview with // no extra question — which is what it was before amend mode existed. // // When the config already has at least one project and the named (or sole) // target is unknown, this is an add: seed under projects// so the new // fleet never shares the first project's flat worktrees/mirrors (#319). const prior = priorProject(existing, projectArg); if (prior === undefined) { const adding = (existing?.projects.length ?? 0) > 0; const answers = await collectAnswers(ui, undefined, projectArg, probes, { added: adding }); // An add that typed an already-configured name would silently replace via // buildConfig. Ask first — amend (carry-through full interview of that // project) or replace (answers as collected, roots as answered). const named = existing?.projects.find((p) => p.name === answers.projectName); if (named !== undefined) { const mode = await ui.select( `"${answers.projectName}" is already configured — amend or replace?`, [ { label: "Amend (keep existing settings, re-ask everything pre-filled)", description: "full interview seeded from the saved project; other projects untouched", }, { label: "Replace with these answers", description: "overwrites this project's config entry on apply; other projects untouched", }, ], { key: `duplicate-project-action.${answers.projectName}`, initialIndex: 0 }, ); if (mode === undefined) throw new Cancelled(); if (mode.startsWith("Amend")) { return { answers: await collectAnswers(ui, named, answers.projectName, probes) }; } // Replace: keep the answers just collected (including any scoped roots the // operator saw). buildConfig replaces by name. return { answers }; } return { answers, added: adding || undefined, }; } // A positional CLI area skips the chooser: the operator already named which // area they came to amend, so the first dialog is that area's first question // rather than a menu of every area. if (areaArg !== undefined) { return { answers: await AREA_ASKERS[areaArg](ui, answersFromProject(prior), probes), amend: { area: areaArg, before: prior }, }; } const choice = await chooseAmendArea(ui, prior, existing?.defaults ?? DEFAULT_CAPS); if (choice.kind === "add-project") { // Fresh interview, no pre-fill from the neighbour. CLI --project is ignored // here: the operator just chose to add, so the name comes from the prompt. const answers = await collectAnswers(ui, undefined, undefined, probes, { added: true }); const named = existing?.projects.find((p) => p.name === answers.projectName); if (named !== undefined) { const mode = await ui.select( `"${answers.projectName}" is already configured — amend or replace?`, [ { label: "Amend (keep existing settings, re-ask everything pre-filled)", description: "full interview seeded from the saved project; other projects untouched", }, { label: "Replace with these answers", description: "overwrites this project's config entry on apply; other projects untouched", }, ], { key: `duplicate-project-action.${answers.projectName}`, initialIndex: 0 }, ); if (mode === undefined) throw new Cancelled(); if (mode.startsWith("Amend")) { return { answers: await collectAnswers(ui, named, answers.projectName, probes) }; } return { answers }; } return { answers, added: true }; } if (choice.kind === "reinterview") { // Full interview of the *existing* name is a replace of that project. Ask // once so a silent overwrite cannot happen from muscle-memory Enter. const replace = await ui.confirm( `Replace existing project "${prior.name}"?`, "Walks every question again and overwrites this project's config entry on apply. " + "Other projects are left alone. Cancel and pick \"Change one area\" to amend without a full replace, " + "or \"Add another project\" to create a neighbour.", { key: `replace-project.${prior.name}` }, ); if (replace !== true) throw new Cancelled(); return { answers: await collectAnswers(ui, prior, projectArg, probes) }; } return { answers: await AREA_ASKERS[choice.area](ui, answersFromProject(prior), probes), amend: { area: choice.area, before: prior }, }; } /** * Operator steps herdr requires for a new fleet pane. Verified against * `herdr workspace create` / `herdr agent start` help and recover.sh bootstrap: * create a workspace at the project's cwd, then start omp *into* that pane with * `--kind omp --pane `. Do **not** invent `agent start --session/--cwd` — * `agent start` requires an existing pane at a shell prompt, and starting into a * live orchestrator pane is refused or worse. */ export function formatHerdrHandoff(project: ProjectConfig, cfg: ConductorConfig): string { const session = process.env["HERDR_SESSION"] ?? "conductor"; // Every entry is the cwd that owns that project's tick config — the directory its // pane runs in — not `workspaceRoot`. They differ on any fleet whose config // predates per-project roots, and recovery keys on the tick config. const paneCwd = tickCwdForProject(project); const cwds = cfg.projects.map((p) => tickCwdForProject(p)).join(":"); return [ "herdr handoff (CLI cannot do these — exact herdr argv):", ` 1. In session "${session}", create a workspace for project "${project.name}" at its fleet cwd:`, ` herdr --session ${session} workspace create --cwd ${paneCwd} --label ${project.name} --no-focus`, " Read root_pane.pane_id from the JSON reply (jq -r '((.result // .).root_pane.pane_id) // empty').", ` 2. Start omp in that empty pane. The agent NAME must match tick config agentName ("${project.name}"):`, ` herdr --session ${session} agent start ${project.name} --kind omp --pane `, " The pane must be at a shell prompt with no agent on it. Never agent-start into a live orchestrator pane.", ` 3. Point herdr-conductor recovery at every fleet cwd (colon-separated; see #320):`, ` FLEET_CWDS=${cwds}`, ` Legacy single-fleet hosts can keep FLEET_CWD=${paneCwd} until multi-fleet recovery lands.`, " 4. Reload the daemon so it serves every configured project:", " omp-conductor restart --now", " (printed, not auto-run, when workers are live or a neighbour was just added)", ].join("\n"); } /** * The side-effecting apply tail of {@link setup}, as one injectable object — the * wizard has `collectSetup` fully covered but none of its apply tail today. The * default is the real implementations; a test stubs whichever of gh/daemon/root * it proves, and drives the real tail (including the real `runGraphInstall` * with injected `unitDir`/`privileged`/probe) through the same seam, so a * regression cannot hide behind a rewritten status line. */ export interface SetupApplyDeps { scopes: () => Promise; labels: (trackerRepo: string, a: SetupAnswers) => Promise; preview: (project: ProjectConfig) => Promise; createLabels: (trackerRepo: string, plan: LabelPlan[]) => Promise; /** * Compensation half of {@link createLabels}: deletes exactly the labels a * failed apply created, so the tracker returns to its pre-entry state. * Called only on rollback, where a deletion failure is a reported * restoration failure — the orphaned label is named, never silently left * behind (#652). */ deleteLabels: (trackerRepo: string, created: string[]) => Promise; /** * Proves a healthy runtime generation serves the COMPLETE prior configured * project set after a failed apply rolled the disk state back (#650). The * surviving entry-generation process predates the rollback: its store * handle points at the file the rollback replaced (writes diverge into the * unlinked inode until its next restart) and its in-memory config may be * the rejected generation — so "proving health" is never the proof. The * default implementation restarts through the same lifecycle seam the * apply's own restart leg uses and then proves /healthz names every prior * project. Returns false when the proof cannot be made — the caller then * stays fail-closed (a setup hold stays in force, with the recovery * command named) rather than resuming dispatch onto an unproven runtime. */ proveRuntime: (priorProjects: readonly string[]) => Promise; /** * The lifecycle/drain seam the acknowledged quiescence barrier runs on — * the same `DrainDeps` `upgrade`, `restart` and `setup host` use, so the * freeze, its token proof and its staleness checks are the seam's, never a * second counter-only fence (#618). Injectable so a regression can drive * the exact admission interleaving through real barrier code. */ drain: DrainDeps; smoke: (project: string) => Promise; restart: (o: { project?: string }) => Promise; /** * Prove the heartbeat channel for one project. `progress` is where the * pending proof's own heartbeat is written while it waits on the operator * (#861) — the wizard owns the surface, so the reporter comes from the call. */ arm: (project: string, progress?: (line: string) => void) => Promise; hostInstall: ( project: ProjectConfig, caps: Caps, telegramStateDir: string, ui: WizardUi, ) => Promise; graphInstall: ( project: ProjectConfig, ui: WizardUi, options?: GraphInstallOptions, ) => Promise; } /** * The default drain accessors the setup apply barrier runs on, wired exactly * like `setup host`'s (`setup-install.ts`) — except the pause names `setup` * as the actor and the barrier refuses on live workers instead of waiting for * a drain. * * Two deliberate divergences from the drop-in lifecycle accessors, both * because a setup apply owns a *host-global* transaction rather than the one * project a restart drains: * * - `snapshot` counts live workers through {@link liveWorkersReadOnly}, never * `statusSnapshot`: the observation that gates the barrier must be * read-only, because database preparation is itself one of the mutations * the barrier precedes (#651 review #2). * - `layers` reports the *global* sentinel's paused flag, not the project * bare-read `fleetLayers` derives — the barrier freezes the whole host * through the one global pause sentinel (`scope.pauseKey === undefined`), * so the fence it must re-prove is that sentinel, never a per-project one * (#651 review #4). */ function defaultSetupDrain(): DrainDeps { return { snapshot: (project) => ({ liveWorkers: liveWorkersReadOnly(project) }), layers: () => ({ ...fleetLayers(), paused: isPaused() }), projectNames: () => loadConfig().projects.map((p) => p.name), daemonIdentity: setupDaemonIdentity, pauseState: (project) => pauseInstance(project), setPaused: (v, project) => // `owner` is this process (#938): the apply fence holds dispatch across // this transaction only, and the 2026-08-21 incident is exactly what an // abandoned one looks like from outside. setPaused( v, { source: pauseSourceToken("setup"), reason: "setup apply fence", owner: process.pid }, project, ), // The daemon-side admission acknowledgement: the file the running daemon // itself writes when it observes the fence at an admission boundary, so // the barrier never mistakes a second synchronous count for the daemon's // own word that it will claim nothing (#651 review #3). admissionAck: () => readAdmissionAck(), sleep: Bun.sleep, log: () => {}, }; } /** * The daemon identity the setup barrier acknowledges and re-proves: the known * pidfile record when one is live, else an ACTIVE `omp-conductor.service` * unit whose runtime record went missing — a supervised daemon is still a * running daemon without its record, and its MainPID is the generation that * identifies the exact instance (#651, review #2). An UNKNOWN systemd query * throws: "dbus blipped" is not "no daemon", and the barrier must fail closed * rather than acknowledge quiescence it cannot prove. */ function setupDaemonIdentity(): DaemonIdentity { const ownership = probeUnit(SYSTEMD_UNIT); if (ownership.kind === "unknown") { throw new Error( `cannot prove whether the daemon is running: systemd ownership probe failed (${ownership.reason})`, ); } const daemon = livingDaemon(); if (daemon !== undefined) { // The record names the exact instance; `daemonGeneration` formats the // same generation the daemon's own admission acknowledgement records, so // the two sides of the acknowledged fence always compare like for like. return { running: true, project: daemon.project, generation: daemonGeneration() }; } if (ownership.kind === "active") { // The unit owns a live MainPID but the record is absent (a crash raced a // re-record, an unmanaged start never wrote one): the daemon is running, // and the MainPID is the generation that lets the fence spot a restart. return { running: true, generation: `systemd:${ownership.pid}` }; } return { running: false }; } export const DEFAULT_APPLY: SetupApplyDeps = { scopes: checkTokenScopes, labels: planLabels, preview: previewProject, createLabels: createMissingLabels, deleteLabels: deleteCreatedLabels, proveRuntime: async (priorProjects) => { // No short-circuit on a healthy daemon: whatever process is still // answering predates the rollback, so it holds a handle on the store file // the rollback replaced and may serve the rejected config. Restart // through the same fail-closed lifecycle seam the apply's own restart leg // uses — whose `waitForOwnedDaemon` proves MainPID + /healthz and that a // fresh boot loaded the restored/rolled-back state — then prove the new // generation serves EVERY prior configured project, not just health on // one (#650, PR #700 review #1). try { const restarted = await restartDaemon({}); const health = await healthCheck(restarted.record.port); if (!health.ok) return false; return priorProjects.every((project) => healthServesProject(health.body, project)); } catch { return false; } }, drain: defaultSetupDrain(), smoke: runSetupSmoke, restart: (o) => restartDaemon(o), arm: (project, progress) => ensureSetupArm(project, armTicks, progress), hostInstall: (project, caps, telegramStateDir, ui) => runHostInstall(project, caps, telegramStateDir, ui), graphInstall: (project, ui, options) => runGraphInstall(project, ui, options), }; /** One pause sentinel's pre-entry state, four-valued so absence, readable-valid * bytes, malformed bytes and unreadability are never conflated. * - `{ kind: "absent" }` — no file on entry; rollback removes one the barrier * created. * - `{ kind: "bytes", bytes }` — readable AND valid on entry; rollback writes * these bytes back verbatim, so the pre-entry owner token and timestamp * survive a refused apply instead of being replaced by a fresh equivalent * pause (#618, continuation #7). * - `{ kind: "malformed", bytes, path }` — readable but not a valid pause * instance (an unparseable timestamp or provenance line). This is * unreadable-as-state: the barrier must fail closed BEFORE changing * anything and never delete it, because collapsing it to absence would * remove an operator hold it could not prove (#650). * - `{ kind: "unreadable", path }` — the file exists but cannot be read. The * barrier must fail closed BEFORE changing anything: collapsing this to * absence and later deleting the path would remove an operator hold it * never saw (#651 review #5). */ type CapturedPause = | { kind: "absent" } | { kind: "bytes"; bytes: string } | { kind: "malformed"; bytes: string; path: string } | { kind: "unreadable"; path: string }; /** Reads one pause sentinel, keeping absence, readable-valid bytes, malformed * bytes and unreadability apart — never conflated: collapsing malformed to * absence would let a refusal delete an operator hold it could not parse. */ function readSentinel(p: string): CapturedPause { if (!existsSync(p)) return { kind: "absent" }; try { const bytes = readFileSync(p, "utf8"); return pauseBytesValid(bytes) ? { kind: "bytes", bytes } : { kind: "malformed", bytes, path: p }; } catch { return { kind: "unreadable", path: p }; } } /** * The pre-entry pause sentinel the setup apply itself can change. Setup * freezes the whole host through the one *global* sentinel — it must never * write a project's pause instead, which left the wrong project held when * adding a neighbour next to a daemon recorded for the existing project * (#651 review #4) — so the captured path is the global one, its scope is * preserved exactly (a successful apply or a refusal restores that same * path's pre-entry bytes, never a project-scoped sentinel), and an entry * that is already paused — valid, malformed or unreadable — fails closed in * its own way rather than being rebuilt or deleted (#650). */ function capturePause(): CapturedPause { return readSentinel(pausedPath()); } /** Restores the sentinel {@link capturePause} captured — but only while the * sentinel on disk is still the fence THIS transaction froze (the bytes it * wrote, or the pre-entry hold it inherited). A `resume` or a newer operator * hold that replaced the setup freeze is a takeover the stale transaction * must preserve: writing the entry bytes back over it, or removing the file * it owns, would delete a safety hold a later actor wrote in the meantime * (#651 review #3). An unreadable or malformed current sentinel is also left * alone — overwriting what cannot be read (or parsed) is the same * overwrite-of-the-unseen this guard exists to prevent. Never called with a * malformed entry state: the barrier fails closed before mutating then; the * branch exists so a malformed state would still be restored byte for byte * rather than deleted, never treated as absence. */ function restorePause(state: CapturedPause, owned: CapturedPause): void { const p = pausedPath(); const current = readSentinel(p); // Only this transaction's own fence may be undone. An unreadable or // malformed sentinel is left alone (overwriting what cannot be read or // parsed is overwriting a hold the barrier never saw); a sentinel that no // longer matches the bytes this transaction froze was replaced by a takeover // — a resume or a newer hold — and that takeover stands. let isOwnFence = false; if (current.kind === "bytes" && owned.kind === "bytes") { isOwnFence = current.bytes === owned.bytes; } else if (current.kind === "absent" && owned.kind === "absent") { isOwnFence = true; } if (!isOwnFence) return; if (state.kind === "bytes" || state.kind === "malformed") { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, state.bytes); } else { rmSync(p, { force: true }); } } // -------------------------------------------------- the mutation inventory -- // // Child 2 of the setup-transaction decomposition (#652): before the first // mutation the apply records the COMPLETE inventory of every surface it can // write — config, briefs, staged units/scripts/tick, the AGENTS link, the run // store, the daemon record, the arm marker, the config backups and the // tracker labels — with byte-exact pre-entry state. Any later exception or // failed smoke restores every one of them (or removes what a first install // created), compensates the tracker exactly, and reports the first // restoration failure honestly instead of pretending the prior state is // coherent. The one surface a rollback never undoes blindly is the pause // sentinel: {@link restorePause} keeps child 1's ownership rule — only the // fence THIS transaction froze is undone, and a newer actor's hold stands. /** The run store's pre-entry state: a byte-consistent snapshot when the * store exists, absence on a first install. */ interface StoreCapture { path: string; kind: "absent" | "snapshot"; snapshotPath?: string; } /** The complete pre-entry mutation inventory of one setup apply (#652). */ interface SetupInventory { /** `config.json` pre-entry state (bytes, absence, or unreadable). */ config: CapturedPathState; /** The pre-entry configured project set — what the prior runtime generation * must be proven to serve after a rollback restores the config (#650). */ priorProjects: string[]; /** The brief files the apply will write: `POLICY.md` + `ORCHESTRATOR.md`. */ briefs: CapturedPathState[]; /** Every staged host-runtime path the apply may write (units, scripts, tick). */ runtime: CapturedPathState[]; /** The AGENTS.md brief link the apply will create/update, when planned. */ briefLink?: CapturedPathState; /** The console floor and its AGENTS.md link, when the apply will write them. * Captured separately from {@link runtime} because the link is a symlink and * restores as one — and because a half-applied console left behind after a * failed apply is a session pointing at a brief this transaction rolled back. */ consoleBrief?: CapturedPathState; consoleLink?: CapturedPathState; /** The run store: byte-consistent snapshot when it exists, absence on a first install. */ store: StoreCapture; /** The daemon pidfile record the smoke/restart legs write. */ record: CapturedPathState; /** The external-heartbeat arm marker, when an external orchestrator is planned. */ armMarker?: CapturedPathState; /** Directories the apply may create (workspace/tick cwds), absent at entry; * rollback removes them only while still empty, so operator content that * landed in one is never destroyed. */ dirs: string[]; /** Pre-entry listing of the config backup dir; backups the apply created * are removed on rollback so the backups return to their entry set. */ backups: { dir: string; files: string[] }; } /** A byte-consistent pre-entry copy of the run store: VACUUM INTO reads main * file + WAL as one, so the snapshot holds every committed row even while a * paused daemon holds a writer handle. The snapshot lives under the state * dir for the transaction's lifetime and is removed on commit and rollback * alike; a partial snapshot from a throwing VACUUM is removed immediately, * never left as a stray temp copy in the state dir (#650, review #2). */ function captureStore(path: string): StoreCapture { if (!existsSync(path)) return { path, kind: "absent" }; const snapshotPath = join(stateDir(), `.conductor.db.setup-${process.pid}.${randomUUID()}.snapshot`); try { vacuumInto(path, snapshotPath); } catch (err) { rmSync(snapshotPath, { force: true }); throw err; } return { path, kind: "snapshot", snapshotPath }; } /** The backup dir listing, or the empty set when the dir does not exist yet. */ function readdirIfPresent(dir: string): string[] { try { return readdirSync(dir).sort(); } catch { return []; } } /** * The full pre-entry mutation inventory of one apply, read BEFORE the first * mutation. Throws when any captured path exists but cannot be read: an * unreadable pre-entry path is a fault, never absence — collapsing it would * let a rollback delete state the apply never saw, the same fail-closed rule * the pause sentinel capture already applies (#651 review #5). * * `priorProjectNames` is the pre-entry configured project set (from the * config `setup()` loaded before the interview): the set a failed apply must * prove the restored runtime serves again. It comes from the entry load, not * a post-rollback re-read, so an un-restored config cannot make the proof * measure the wrong set (#650). */ function captureInventory( plan: DerivedPlan, answers: SetupAnswers, priorProjectNames: string[], ): SetupInventory { const runtime: CapturedPathState[] = []; const captureIfWritten = (w: { action: string; path: string } | undefined): void => { // Only paths the plan says will actually be written: a `keep` path is // untouched by the apply and needs no pre-entry state. if (w !== undefined && w.action !== "keep") runtime.push(capturePathState(w.path)); }; captureIfWritten(plan.runtime.service); captureIfWritten(plan.runtime.herdrUnit); captureIfWritten(plan.runtime.herdrConfig); captureIfWritten(plan.runtime.herdrEnv); captureIfWritten(plan.runtime.recoverUnit); captureIfWritten(plan.runtime.recoverScript); captureIfWritten(plan.runtime.tick); const briefs = answers.writeOrchestratorBrief ? [policyPathForProject(plan.project), briefPathForProject(plan.project)].map(capturePathState) : []; const briefLink = plan.runtime.briefLink !== undefined && (plan.runtime.briefLink.action === "create" || plan.runtime.briefLink.action === "update") ? capturePathState(plan.runtime.briefLink.path) : undefined; // The console workspace: same "only what will be written" rule, and the link // is captured only when the plan will touch it — a `skip` is an operator's // own file the apply never writes, so it has no pre-entry state to restore. const consoleBrief = plan.runtime.console !== undefined && plan.runtime.console.brief.action !== "keep" ? capturePathState(plan.runtime.console.brief.path) : undefined; const consoleLink = plan.runtime.console !== undefined && (plan.runtime.console.link.action === "create" || plan.runtime.console.link.action === "update") ? capturePathState(plan.runtime.console.link.path) : undefined; const record = capturePathState(recordPath()); const armMarker = plan.project.escalation.orchestrator === "external" ? capturePathState(armedMarkerPath(plan.project.name)) : undefined; // Unreadable check across every captured path, before anything is written. const all = [ capturePathState(configPath()), ...briefs, ...runtime, ...(briefLink === undefined ? [] : [briefLink]), ...(consoleBrief === undefined ? [] : [consoleBrief]), ...(consoleLink === undefined ? [] : [consoleLink]), record, ...(armMarker === undefined ? [] : [armMarker]), ]; const unreadable = all.find((c) => c.kind === "unreadable"); if (unreadable !== undefined) { throw new Error( `the path ${unreadable.path} exists but could not be read — refusing to start the apply rather than ` + "risk overwriting state it never saw", ); } // Directories the apply can create (the workspace root via the brief write, // the fleet cwd via the AGENTS link and the tick config). Only absent ones // are recorded; rollback removes them only while still empty. const dirs = new Set(); if (answers.writeOrchestratorBrief) dirs.add(dirname(briefPathForProject(plan.project))); if (briefLink !== undefined) dirs.add(dirname(briefLink.path)); // The console cwd, created by the brief write or the link. if (consoleBrief !== undefined) dirs.add(dirname(consoleBrief.path)); if (consoleLink !== undefined) dirs.add(dirname(consoleLink.path)); if (plan.runtime.tick !== undefined && plan.runtime.tick.action !== "keep") { dirs.add(dirname(plan.runtime.tick.path)); } return { config: all[0]!, priorProjects: priorProjectNames, briefs, runtime, ...(briefLink === undefined ? {} : { briefLink }), ...(consoleBrief === undefined ? {} : { consoleBrief }), ...(consoleLink === undefined ? {} : { consoleLink }), store: captureStore(dbPath()), record, ...(armMarker === undefined ? {} : { armMarker }), dirs: [...dirs].filter((d) => !existsSync(d)), backups: { dir: configBackupDir(), files: readdirIfPresent(configBackupDir()) }, }; } /** * Restores every captured surface on a failed apply (#652): the config and * the backups the apply created, the briefs, the staged runtime files, the * AGENTS link, the run store, the daemon record and the arm marker; removes * what a first install created; and compensates tracker labels exactly. * Returns every restoration failure — the first included — so the caller * reports the rollback honestly instead of implying the prior state is * coherent. Never touches the pause sentinel: that restore keeps child 1's * ownership rule and is the caller's. */ async function restoreInventory( inventory: SetupInventory, trackerRepo: string, createdLabels: string[], deleteLabels: SetupApplyDeps["deleteLabels"], ): Promise { const failures: string[] = []; const restore = (state: CapturedPathState | undefined, opts?: RestorePathOptions): void => { if (state === undefined) return; const failure = restorePathState(state, opts); if (failure !== undefined) failures.push(failure); }; // The config first — the file everything else derives from. Restored // through the atomic temp-then-rename writer the upgrade rollback uses, so // a crash mid-restore cannot leave a truncated config; the backup that // writer makes of the failed config is pruned with the rest of the apply's // new backups below, returning the backup dir to its entry listing. if (inventory.config.kind === "bytes") { try { writeConfigRaw(inventory.config.bytes); } catch (err) { failures.push( `could not restore config.json: ${err instanceof Error ? err.message : String(err)}`, ); } } else { restore(inventory.config); } const currentBackups = readdirIfPresent(inventory.backups.dir); for (const name of currentBackups) { if (inventory.backups.files.includes(name)) continue; try { rmSync(join(inventory.backups.dir, name), { force: true }); } catch (err) { failures.push( `could not remove backup ${join(inventory.backups.dir, name)}: ${err instanceof Error ? err.message : String(err)}`, ); } } for (const brief of inventory.briefs) restore(brief); for (const path of inventory.runtime) restore(path); // The AGENTS link: only a symlink is ever removed or re-linked, so an // operator's regular file that appeared mid-flight survives — reported as a // failure, because the prior state is then not coherent. restore(inventory.briefLink, { preserveRegularFile: true }); // The console floor, then its link, on the same terms: the floor is an // ordinary file, and the link is a symlink that never displaces a regular // file that appeared mid-flight. restore(inventory.consoleBrief); restore(inventory.consoleLink, { preserveRegularFile: true }); // The run store: replace from the byte-consistent snapshot (or remove it // entirely on a first-install rollback). try { rmSync(inventory.store.path, { force: true }); rmSync(`${inventory.store.path}-wal`, { force: true }); rmSync(`${inventory.store.path}-shm`, { force: true }); if (inventory.store.kind === "snapshot") { copyFileSync(inventory.store.snapshotPath!, inventory.store.path); } } catch (err) { failures.push( `could not restore the run store at ${inventory.store.path}: ${err instanceof Error ? err.message : String(err)}`, ); } // The daemon record: only while no daemon is live. A live process owns its // record — restoring the entry bytes over it would describe a daemon that // no longer runs, or remove the record of one that does. if (livingDaemon() === undefined) restore(inventory.record); restore(inventory.armMarker); // Directories the apply created: removed only while empty (rmdirSync, never // a recursive rm — operator content that landed in one must survive). for (const dir of inventory.dirs) { try { rmdirSync(dir); } catch { // Non-empty, missing, or busy — leave it; this is not a restoration failure. } } // Tracker compensation: exactly the labels THIS apply created. if (createdLabels.length > 0) { try { await deleteLabels(trackerRepo, createdLabels); } catch (err) { failures.push(err instanceof Error ? err.message : String(err)); } } return failures; } /** * The host-global scope a setup apply's barrier covers: every configured * project served by the daemon, and the global pause sentinel so one write * pauses the whole host. Deliberately NOT {@link resolveScope}'s * project-narrowing — which would pause the daemon's recorded project's * sentinel instead of the host-wide one when adding a neighbour next to it * (review #4). A first install has no config and no daemon, so the scope names * the bare selector and the observation proves zero without any reader. */ function setupScope(drain: DrainDeps): UpgradeScope { let configured: readonly string[] = []; try { configured = drain.projectNames(); } catch (err) { // An existing config that cannot be enumerated is a fault, never absence: // collapsing it to the bare selector would read "zero workers" for a fleet // the config still describes, then overwrite the unreadable file (#651, // review #2). Only a truly absent config legitimately yields the bare // selector (a first install), and even there the observation still fails // closed against the daemon identity. if (existsSync(configPath())) { throw new Error( `the configured projects could not be read: ${err instanceof Error ? err.message : String(err)} — ` + "zero workers cannot be proven while the config is unreadable", ); } } // A daemon serves projects from its own memory, not from the on-disk // config: a project the config no longer names (or never named — a neighbour // this host is mid-migrating to) still owns live runs the daemon admits and // sweeps. If the daemon's recorded project is not among the configured // ones, the host-global scope must still cover it, or its workers are // silently skipped while setup mutates underneath them (#651 review #3). An // identity that cannot be proven throws here — before any freeze — so the // scope never reads "zero" for a fleet it cannot see. const daemon = drain.daemonIdentity(); if (daemon.running && daemon.project !== undefined && !configured.includes(daemon.project)) { configured = [...configured, daemon.project]; } return { selectors: configured.length === 0 ? [undefined] : configured, pauseKey: undefined, }; } /** Live worker runs across every project a host-wide transaction covers. * Throws — refusing — when the fleet cannot be proven quiescent: a missing * config does not prove zero workers while a daemon runs from its own * in-memory config and still owns runs (review #3), and an unreadable store * is a fault, never absence. */ function liveWorkersInScope(drain: DrainDeps, scope: UpgradeScope): number { if (!existsSync(configPath()) && drain.daemonIdentity().running) { throw new Error( "config.json is absent while a daemon is running, so zero workers cannot be proven; " + "the daemon serves projects from memory", ); } return scope.selectors.reduce((n, s) => n + drain.snapshot(s).liveWorkers, 0); } /** How long the barrier waits — after one best-effort daemon wake — for the * running daemon to acknowledge the setup fence before refusing with nothing * written. Bounded and fail-closed: an unacknowledged fence is an unproven * one, and the answer is a re-run, never an unproven proceed. */ const FENCE_ACK_WAIT_MS = 60_000; const FENCE_ACK_POLL_MS = 250; /** * Why the daemon's admission acknowledgement no longer covers the fence the * barrier froze, or `undefined` when it does: the acknowledgement must name * the exact pause instance the barrier proved (not some other pause, however * similar) and the exact daemon generation the barrier began with (not an * older or newer instance's word) (#651 review #3). Every problem names the * observed and expected instances, so an operator can tell "no * acknowledgement yet" from "acknowledged something else" without opening a * debugger (#865). */ function fenceAckProblem(ack: AdmissionAckRecord | undefined, begun: RestartBegun): string | undefined { if (ack === undefined) { return ( "the running daemon has not acknowledged the setup admission fence " + `(fence ${describePauseInstance(begun.pauseToken)}; expected daemon generation ${begun.daemon.generation}; ` + `no readable acknowledgement at ${admissionAckPath()})` ); } if ( ack.pause.source !== begun.pauseToken.source || ack.pause.since !== begun.pauseToken.since || ack.pause.reason !== begun.pauseToken.reason ) { return ( "the running daemon acknowledged a different pause than the setup admission fence " + `(acknowledged ${describePauseInstance(ack.pause)}; fence ${describePauseInstance(begun.pauseToken)})` ); } if (ack.daemon !== begun.daemon.generation) { return ( "the running daemon's acknowledgement belongs to a different daemon generation " + `(acknowledged ${ack.daemon}; expected ${begun.daemon.generation})` ); } return undefined; } /** One pause instance as one readable token for refusal diagnostics (#865). */ function describePauseInstance(pause: { source: string; reason?: string; since: number }): string { const reason = pause.reason === undefined ? "" : ` reason="${pause.reason}"`; return `source=${pause.source}${reason} since=${new Date(pause.since).toISOString()}`; } /** * Where a prompt-pass wake can provably land, or `undefined`: the pidfile * record's port while its process lives, else — a supervised daemon whose * runtime record went missing while the unit stayed active (#651 review #2) — * the run-control endpoint {@link daemonControlTarget} proves by answering * `/healthz`, the same consultation every run-control verb performs (#716). * A port is never guessed: when nothing provable answers, no wake is sent and * the caller's bounded wait covers the daemon's own next pass instead. The * pre-#865 barrier skipped BOTH the wake and the wait whenever the record was * unreadable, so a transient record loss refused three interviews in a row * against a healthy dispatcher that would have acknowledged within one tick. */ async function reachableWakeEndpoint(): Promise<{ port: number } | undefined> { const target = await daemonControlTarget(); return target.kind === "record" || target.kind === "unit" ? { port: target.port } : undefined; } /** * Why the acknowledged barrier no longer holds, or `undefined` when it does: * a live worker anywhere in scope, the daemon-side admission acknowledgement * no longer covering the frozen fence (when one is required), or the fence * its own pause/daemon-generation check (the reused lifecycle seam, * {@link restartFenceProblem}) says was lifted or replaced. The acknowledged * quiescence barrier re-runs this after every async boundary it must not * proceed across (#651 review #6). */ function barrierDispute( drain: DrainDeps, scope: UpgradeScope, begun: RestartBegun, requireAck: boolean, ): string | undefined { try { const live = liveWorkersInScope(drain, scope); if (live > 0) return `${live} live worker(s) appeared under the setup admission freeze`; if (requireAck && begun.daemon.running) { const problem = fenceAckProblem(drain.admissionAck?.(scope.pauseKey), begun); if (problem !== undefined) return problem; } // The fence's own pause/daemon-generation proof (the reused lifecycle // seam) — wrapped with the worker count so a throwing accessor (an // unknown systemd probe in the identity, say) is a dispute to refuse on, // never an exception escaping with the freeze still written. return restartFenceProblem(drain, scope, begun, "setup"); } catch (err) { return err instanceof Error ? err.message : String(err); } } /** * The post-apply code-graph offer. Shared by the tail and the live-worker early * return, so neither path can silently leave the graph install unreferenced. * `graphInstall` is injectable: the tail and the CLI both call the real * {@link runGraphInstall}, and a test drives apply through this same seam. */ async function offerCodeGraph( project: ProjectConfig, ui: WizardUi, graphInstall: SetupApplyDeps["graphInstall"], ): Promise { if (graphRepos(project).length === 0) return; const wantsGraph = await ui.confirm( "Install the code-graph indexes now?", "Clones each index-only checkout as you, installs and enables the reindex timer as root, then seeds one " + "indexing run so the first fetch happens while you watch. Minutes per repo. " + "`omp-conductor setup graph` does the same later; `--no-seed` skips the seeding run.", { key: `offer-code-graph.${project.name}` }, ); if (wantsGraph === true) await graphInstall(project, ui); else ui.notify( "Skipped the code graph — workers grep until it exists. When you want it: omp-conductor setup graph", "info", ); } export async function setup( ui: WizardUi, projectArg: string | undefined, areaArg?: AmendAreaId, probes: SetupProbes = DEFAULT_PROBES, apply: SetupApplyDeps = DEFAULT_APPLY, ): Promise { const path = configPath(); // A config that exists but does not parse is a fault to report, never // something to quietly replace: overwriting it would delete every project it // describes. Absence, by contrast, is just the first run. const existing = existsSync(path) ? loadConfig() : undefined; if (existing === undefined) { ui.notify(`No config at ${path} yet — let's make one. Nothing is written until you confirm.`, "info"); } let collected: CollectedSetup; try { collected = await collectSetup(ui, existing, projectArg, areaArg, probes); } catch (err) { if (!(err instanceof Cancelled)) throw err; ui.notify("Setup cancelled — nothing was changed.", "info"); return false; } // `answers` is mutable below: the review loop re-asks one area at a time and // replaces only the fields that area owns, so everything else carries across. const { amend } = collected; let answers = collected.answers; const scopes = await apply.scopes(); if (!scopes.ok) { ui.notify( `Setup stopped before writing anything. The gh token needs repo and project scopes. ` + `Run \`gh auth refresh -s repo,project\`, then run setup again.`, "error", ); return false; } // Drafted here — after the interview, before the plan — because the plan's single // consent gate says "Writes ORCHESTRATOR.md + POLICY.md", and this is what those // files will contain. Each draft carries its own preview and confirm; declining // one keeps the shipped stub, so an operator who wants none reaches the same plan. // Everything below the interview is *derived* from `answers`, so an edit in // the review loop re-derives it all — including the predictive queue preview, // which is part of the diff the operator approves (#417). The one-time reads // (scope check, label listing, Telegram state, the prose draft) run once and // are cached: a decline followed by a dozen edits must not re-probe GitHub or // the daemon on every pass. const telegram = detectTelegram(); let prose: ProbedProse = {}; let proseDrafted = false; if (answers.writeOrchestratorBrief) { prose = await probes.prose(ui, answers); proseDrafted = true; } // The single label listing, cached for the whole review loop. The plan // re-derives existence against this set after an edit, so a new queue label // or routing key is planned on the same tracker read the first pass used — // a label that in fact already exists is skipped idempotently at apply. const labelListing = await apply.labels(answers.trackerRepo, answers); const knownLabelNames = new Set(labelListing.filter((l) => l.exists).map((l) => l.name)); /** Re-builds everything the plan shows and apply writes from the answers as * they stand right now. Returns `undefined` when a guard aborted (the error * is already shown) — either way, nothing has been written. */ const derive = async (): Promise => { let nextConfig: ConductorConfig; try { nextConfig = buildConfig(answers, existing); } catch (err) { ui.notify( `Setup stopped before writing anything. ${err instanceof Error ? err.message : String(err)}`, "error", ); return undefined; } const project = findProject(nextConfig, answers.projectName); const totalWorkers = totalConfiguredWorkers(nextConfig); const overcommit = workerOvercommit(totalWorkers); // The same project as it is configured right now, so a moved `workspaceRoot` if ( project.escalation.orchestrator === "external" && !answers.writeOrchestratorBrief && (!existsSync(briefPathForProject(project)) || !existsSync(policyPathForProject(project))) ) { ui.notify( `Setup stopped before writing anything. External orchestration needs ${ORCHESTRATOR_BRIEF_NAME} and ${POLICY_BRIEF_NAME}. ` + `Run setup again and approve the brief write.`, "error", ); return undefined; } const runtime = planHostRuntime( project, resolveCaps(project, nextConfig.defaults), telegram.stateDir, undefined, totalWorkers, ); let queuePreview: string[]; try { // The predictive diff: the same routing code the loop runs, re-run on // every pass back so the dry run can only ever describe the answers it // was just asked about (#417). queuePreview = formatPreview(await apply.preview(project)); } catch (err) { const message = err instanceof Error ? err.message : String(err); ui.notify( `Setup stopped before writing anything because the proposed queue could not be read: ${message}`, "error", ); return undefined; } const labels = planAgainstLabels(wantedLabels(answers), knownLabelNames); return { nextConfig, project, runtime, labels, toCreate: labels.filter((l) => !l.exists).map((l) => l.name), totalWorkers, overcommit, queuePreview, }; }; /** The plan screen, exactly as the consent gate shows it after every re-derive: * the delta first when there is one, then the whole plan — the confirm has to * name every mutation it makes, and a delta names none of them. */ const planBlock = (d: DerivedPlan): string => [ ...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers, d.nextConfig.defaults)]), summarisePlan(answers, scopes, d.labels, telegram, d.nextConfig.defaults), "", formatHostRuntimePlan(d.runtime), "", ...(d.overcommit === undefined ? [] : [`WARNING: ${d.overcommit}`, ""]), "Dry run against the PROPOSED config:", ...d.queuePreview, "", "Nothing has been changed yet.", ].join("\n"); /** The consent recap, unchanged from the confirm it replaces: picking "Apply" * is an agreement to exactly the mutations named here. */ const consentRecap = (d: DerivedPlan): string => [ d.toCreate.length > 0 ? `Creates ${d.toCreate.length} label(s) in ${answers.trackerRepo}: ${d.toCreate.join(", ")}.` : "Creates no labels.", `Writes ${path}, prepares a paused state database, then runs a paused daemon smoke.`, d.runtime.service.action === "keep" ? `Keeps the staged systemd unit at ${d.runtime.service.path}.` : `${d.runtime.service.action === "create" ? "Creates" : "Updates"} the staged systemd unit at ${d.runtime.service.path}.`, d.runtime.herdrUnit === undefined ? "" : d.runtime.herdrUnit.action === "keep" ? `Keeps the staged Herdr session unit at ${d.runtime.herdrUnit.path}.` : `${d.runtime.herdrUnit.action === "create" ? "Creates" : "Updates"} the staged Herdr session unit at ${d.runtime.herdrUnit.path} (account's login shell for panes).`, d.runtime.tick === undefined ? "" : d.runtime.tick.action === "keep" ? `Keeps the external heartbeat config at ${d.runtime.tick.path}.` : `${d.runtime.tick.action === "create" ? "Creates" : "Updates"} the external heartbeat config at ${d.runtime.tick.path}.`, d.runtime.briefLink === undefined ? "" : d.runtime.briefLink.action === "skip" ? `Leaves the existing file at ${d.runtime.briefLink.path} alone (an operator's file, not overwritten).` : d.runtime.briefLink.action === "keep" ? `Keeps the brief link at ${d.runtime.briefLink.path}.` : `${d.runtime.briefLink.action === "create" ? "Creates" : "Replaces"} the brief link at ${d.runtime.briefLink.path} -> ${d.runtime.briefLink.target}.`, // The console workspace is a mutation like the rest, so consent names it: // an operator who picked "Apply" must have been told a second session's // cwd and brief appear under the state root. d.runtime.console === undefined ? "" : d.runtime.console.brief.action === "keep" ? `Keeps the operator console brief at ${d.runtime.console.brief.path}.` : `${d.runtime.console.brief.action === "create" ? "Creates" : "Updates"} the operator console workspace at ${d.runtime.console.cwd} (its brief, and an AGENTS.md link to it).`, answers.writeOrchestratorBrief ? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.` : "", d.project.escalation.orchestrator === "external" ? "Dispatch stays paused until the existing arm marker or a new inbound Telegram proof makes the heartbeat live." : "Dispatch resumes after the smoke succeeds and the daemon is proven to serve the config it was just written.", // The acknowledged quiescence barrier (#618): the whole sequence refuses // before the first write while any configured project has a live worker // and until the running daemon has acknowledged the admission fence, so // the consent names the gate the apply runs behind. "Refuses before any write while any configured project has a live worker, and until the running daemon acknowledges the admission fence.", "Issues are only claimed after every setup gate succeeds.", ] .filter((s) => s.length > 0) .join(" "); let plan = await derive(); if (plan === undefined) return false; ui.notify(planBlock(plan), "info"); // -------- the consent gate (#417): a review loop, not a yes/no -------- // // Declining the old "Apply this setup?" confirm dropped every collected // answer on the floor. Now the decline is a loop: edit a named area (through // the same INTERVIEW_AREAS list the interview walks, so the menu's area names // cannot drift from the questions), re-review the resulting plan, or exit // explicitly — and every pass back re-derives the whole plan including the // predictive diff. Nothing is touched before the loop ends on "Apply", which // runs the one-writer sequence below exactly once. A dismissed menu (Ctrl-C / // EOF) is an explicit exit: the old confirm treated dismissal as decline, and // that is the path whose dropped answers must be seen to be dropped. for (;;) { ui.notify(consentRecap(plan), "info"); const choice = await ui.select( amend === undefined ? "Apply this setup?" : `Apply this change to ${AMEND_AREAS[amend.area].name}?`, REVIEW_CHOICES, // Bare Enter lands on Review — the one row with no effect. { key: "setup-review", initialIndex: 2 }, ); if (choice === undefined) { ui.notify("Setup cancelled — nothing was changed, and the answers were discarded.", "info"); return false; } if (choice === REVIEW_APPLY) break; if (choice === REVIEW_EXIT) { ui.notify("Setup exited without applying — the answers were discarded and nothing was changed.", "info"); return false; } if (choice === REVIEW_REVIEW) { ui.notify(planBlock(plan), "info"); continue; } if (choice !== REVIEW_EDIT) { // An option this wizard never offered; do not guess. Mirrors // chooseAmendArea's refusal: nothing was changed. ui.notify(`Unrecognised choice "${choice}" — nothing was changed.`, "warning"); continue; } // Edit: re-enter through the interview's own area list, never a parallel // vocabulary — the review menu's names are the interview's names (#416/#417). const pickedArea = await ui.select( "Edit which area?", INTERVIEW_AREAS.map((area) => ({ label: area.label })), { key: "setup-edit-area", initialIndex: 0 }, ); if (pickedArea === undefined) { ui.notify("Editing cancelled — the answers stand as they were.", "info"); continue; } const area = INTERVIEW_AREAS.find((a) => a.label === pickedArea); if (area === undefined) { // Guessing an area here would ask the wrong questions and carry the rest // through as if it had been reviewed. ui.notify(`Unrecognised choice "${pickedArea}" — nothing was changed.`, "warning"); continue; } let edited: SetupAnswers; try { edited = await area.asker(ui, answers, probes); } catch (err) { if (!(err instanceof Cancelled)) throw err; ui.notify(`Editing "${area.label}" was cancelled — the answers stand as they were.`, "info"); continue; } answers = edited; // The prose draft is the one probe an edit can legitimately make necessary: // flipping the brief on after a first pass had it off (the interview ran // once, not per loop). Still one draft per run. if (answers.writeOrchestratorBrief && !proseDrafted) { prose = await probes.prose(ui, answers); proseDrafted = true; } const redrafted = await derive(); if (redrafted === undefined) return false; plan = redrafted; ui.notify(`Edited "${area.label}" — the plan below is recomputed from the new answers.`, "info"); ui.notify(planBlock(plan), "info"); } // ------------------------------------------------------------------ #618 -- // The acknowledged quiescence barrier. A setup apply replaces config and // runs a paused-daemon smoke that is itself a dispatcher (it settles rows, // projects labels, salvages workers), and every write below happens // underneath a daemon that may still admit or own workers. The fail-closed // envelope, reusing the same lifecycle/drain seam `upgrade`, `restart` and // `setup host` run on rather than a second counter-only fence: refuse before // the first mutation when any configured project has a live worker; freeze // admission host-wide — through the one global sentinel, never a project's — // and prove the freeze is in force (#552); then acknowledge — re-check every // project in scope under the proven freeze, and prove the fence still holds. // A pass admitted between the first zero-worker observation and that // acknowledgement refuses with the exact pre-entry pause restored and // nothing else written. After the acknowledgement, every awaited mutation/ // lifecycle step re-proves the fence and the zero-worker scope before it // acts (a concurrent `resume` during the awaited label I/O is the shape that // used to slip through and orphan an admitted run). // // The pre-acknowledgement refusal paths are all pre-write, so "nothing has // been changed" is literal: no label, no config byte, no brief, no runtime // file, no store write, no smoke and no service mutation. The observation // that gates the barrier is read-only — it can never be the thing that // creates or upgrades the store, because database preparation is itself one // of the mutations the barrier precedes. // The exact pre-entry host-global pause sentinel (owner token and timestamp // byte for byte), captured before anything mutates so a refusal — or the end // of a successful apply — restores it verbatim. Setup freezes the whole host // through the one global sentinel, so that is the only path it may change // (review #4); an unreadable pre-existing sentinel fails closed here, before // any change, rather than being read as absence and deleted (review #5). let scope: UpgradeScope; try { scope = setupScope(apply.drain); } catch (err) { ui.notify( `Setup stopped before writing anything: ${err instanceof Error ? err.message : String(err)}. ` + "Nothing has been changed; fix the config and re-run setup.", "error", ); return false; } const priorPause = capturePause(); if (priorPause.kind === "unreadable") { ui.notify( `Setup stopped before writing anything: the pause sentinel at ${priorPause.path} exists but ` + "could not be read — refusing rather than risk overwriting an operator hold. Nothing has been changed.", "error", ); return false; } // A malformed sentinel is unreadable-as-state: readable bytes that parse as // no pause instance are still an operator's hold, and rebuilding them as a // fresh setup pause would lose the owner identity the fence preserves. Fail // closed before any change and never delete the file (#650). if (priorPause.kind === "malformed") { ui.notify( `Setup stopped before writing anything: the pause sentinel at ${priorPause.path} exists but is ` + "malformed (not a readable pause instance) — refusing rather than rebuild an operator hold setup " + "cannot prove. Nothing has been changed.", "error", ); return false; } // First refusal — before ANY durable mutation, the freeze included. A live // worker in any project the daemon serves would be orphaned, salvaged and // requeued by the config write and the paused-daemon smoke that follow. The // observation is read-only (it never creates or upgrades the store) and // fails closed when quiescence cannot be proven — including when the scope // itself cannot be enumerated (an unreadable config is a fault, never bare // scope, review #2) or the daemon identity cannot be proven (an unknown // systemd probe, review #2). let entryWorkers: number; try { entryWorkers = liveWorkersInScope(apply.drain, scope); } catch (err) { ui.notify( `Setup stopped before writing anything: ${err instanceof Error ? err.message : String(err)}. ` + "Nothing has been changed; re-run setup once the fleet is provably quiet.", "error", ); return false; } if (entryWorkers > 0) { ui.notify( [ `Setup stopped before writing anything: ${entryWorkers} live worker(s) run under the active daemon.`, "A setup apply replaces config and runs a paused-daemon smoke that would orphan, salvage and requeue those runs.", "Let the workers finish, then re-run setup — nothing has been changed.", ].join("\n"), "error", ); return false; } // Freeze admission host-wide, then prove the freeze is in force before // anything is re-checked (#552): a pause that cannot be read back as an // instance is a fence that cannot be acknowledged, so the barrier restores // the exact entry pause and refuses instead of proceeding unproven. const initialPaused = apply.drain.layers(scope.pauseKey).paused; if (!initialPaused) apply.drain.setPaused(true, scope.pauseKey); // The sentinel THIS transaction now owns — the bytes it just wrote, or the // pre-entry hold it inherited. Every later restore only touches the disk // while it still matches these exact bytes, so a takeover (a resume, a // newer hold) is preserved rather than overwritten (#651 review #3). const ownedFence = readSentinel(pausedPath(scope.pauseKey)); const pauseToken = apply.drain.pauseState(scope.pauseKey); if (pauseToken === undefined) { restorePause(priorPause, ownedFence); ui.notify( "Setup stopped before writing anything: the setup freeze could not be proven and was released. " + "Nothing has been changed; re-run setup once dispatch is quiet.", "error", ); return false; } // Acknowledge the barrier: every project in scope is re-checked under the // proven freeze, and the fence must still hold — the pause token we proved // and the daemon generation we began with. A worker admitted after the // first zero-worker observation but before this acknowledgement (a dispatch // pass that passed its own pause gate before the freeze landed), a `resume` // that lifted the freeze, or an identity that can no longer be proven (an // unknown systemd probe) refuses here with the exact pre-entry pause // restored and nothing else written. let begun: RestartBegun; try { begun = { pauseToken, daemon: apply.drain.daemonIdentity() }; } catch (err) { restorePause(priorPause, ownedFence); ui.notify( `Setup stopped before writing anything: ${err instanceof Error ? err.message : String(err)}. ` + "The setup freeze was released; nothing has been changed; re-run setup once the fleet is provably quiet.", "error", ); return false; } // The daemon-side admission acknowledgement (#651 review #3): the fence is // acknowledged only when the daemon ITSELF has observed it at an admission // boundary and written that observation down — a second synchronous worker // count is not an acknowledgement, because a tick already past its own // pause gate can claim after it and before setup mutates. A running daemon // that has not yet acknowledged is woken — through any endpoint /healthz // proves, record or record-less (#865) — to prompt an immediate pass; one // that still has not acknowledged by the bounded deadline refuses before // anything is written. The wait itself never depends on the pidfile: a // supervised daemon with a lost record still reaches its own gate within // one tick interval, and skipping the wait for it was the #865 refusal. let requireAck = true; if (begun.daemon.running) { let ack = apply.drain.admissionAck?.(scope.pauseKey); let problem = fenceAckProblem(ack, begun); if (problem !== undefined) { const wake = await reachableWakeEndpoint(); ui.notify( wake !== undefined ? `The running daemon has not yet acknowledged the setup admission fence — waking it on :${wake.port} to prompt a pass.` : "The running daemon has not yet acknowledged the setup admission fence — nothing provable answers /healthz to wake, so waiting for its own dispatch pass.", "info", ); if (wake !== undefined) void wakeDaemon(wake.port); // `OMP_CONDUCTOR_TEST_FENCE_ACK_WAIT_MS` is the #399 test seam: a // regression proving this refusal burns the short test deadline // instead of the production 60s. const deadline = Date.now() + Number(process.env["OMP_CONDUCTOR_TEST_FENCE_ACK_WAIT_MS"] ?? FENCE_ACK_WAIT_MS); while (problem !== undefined && Date.now() < deadline) { await apply.drain.sleep(FENCE_ACK_POLL_MS); ack = apply.drain.admissionAck?.(scope.pauseKey); problem = fenceAckProblem(ack, begun); } } if (problem !== undefined) { restorePause(priorPause, ownedFence); ui.notify( `Setup stopped before writing anything: ${problem} — quiescence cannot be proven. ` + "Nothing has been changed; stop the daemon, or re-run setup once it has acknowledged the fence.", "error", ); return false; } } const ackDispute = barrierDispute(apply.drain, scope, begun, requireAck); if (ackDispute !== undefined) { restorePause(priorPause, ownedFence); ui.notify( `Setup stopped before writing anything: ${ackDispute}. ` + "Nothing has been changed; re-run setup once dispatch is quiet.", "error", ); return false; } // The re-proof every later awaited-mutation/lifecycle step stands on: the // acknowledged fence must still hold (pause owned, acknowledgement current, // daemon generation unchanged) and no worker may have appeared. A concurrent // `resume` while label creation is pending can reopen admission, after which // config/runtime/smoke would proceed with no further token or worker check // and orphan the admitted run (review #6) — so every one of them re-runs // `barrierDispute` and restores the exact entry pause on a dispute. const barrierHolds = (needAck: boolean): string | undefined => barrierDispute(apply.drain, scope, begun, needAck); // The after-acknowledgement crosscheck: a claim that lands after the // acknowledgement and before the first mutation (a daemon tick that passed // its own pause gate before the freeze and reaches its claim now) must // refuse here with NOTHING written — this read is the one that makes the // `[0, 0, 1]` interleaving a pre-write refusal, not a post-store one // (#651 review #3). The acknowledgement itself cannot see it: the ack is // the daemon's word, and this synchronous crosscheck is the last moment // the envelope can stay literal about "nothing has been changed". const preMutationReproof = barrierHolds(requireAck); if (preMutationReproof !== undefined) { restorePause(priorPause, ownedFence); ui.notify( `Setup stopped before writing anything: ${preMutationReproof}. ` + "Nothing has been changed; re-run setup once dispatch is quiet.", "error", ); return false; } // The one-writer transaction — runs exactly once per setup run, only after // the loop above broke on a final "Apply", and only under the acknowledged // barrier held above. Database preparation is the first mutation, in the // order the envelope promises: barrier first, then store, config, // brief/runtime files, smoke, service activation, tracker labels, the // heartbeat proof last — immediately before the commit. The labels sit // BEFORE the heartbeat proof so the deliberate arm-failure hold keeps the // tracker's planned labels while it holds the fleet (#650, PR #700 review // #3); every rollback path after them compensates exactly through the // inventory, and every failure path before them has created none (#652). A // throw anywhere in this leg, or a re-proof dispute after the first // mutation, rolls the WHOLE inventory back as one unit; the pre-write // refusal paths above are untouched. let created: string[] = []; let briefPath: string | undefined; let runtimeFiles: HostRuntimeWrite = { wrote: [], warnings: [] }; let smoke: SetupSmokeResult | undefined; let smokeLine = ""; let restartVia: "systemctl" | "cli" | undefined; let armLine = "embedded orchestrator — no heartbeat arm marker"; // The complete pre-entry mutation inventory (#652): every path the apply // can write — config, briefs, staged units/scripts/tick, the AGENTS link, // the run store, the daemon record, the arm marker, the config backups — // with byte-exact pre-entry state, read BEFORE the first mutation so a // failure anywhere in the write/smoke/activation leg can restore all of // them. An unreadable pre-entry path refuses here: nothing has been changed. let inventory: SetupInventory; try { inventory = captureInventory(plan, answers, existing?.projects.map((p) => p.name) ?? []); } catch (err) { // The freeze was already written by this point, so it is released with // the refusal — an unreadable pre-entry path must not leave a setup-owned // sentinel wedging dispatch for every project (review #2). restorePause(priorPause, ownedFence); ui.notify( `Setup stopped before writing anything: ${err instanceof Error ? err.message : String(err)}. ` + "The setup freeze was released; nothing has been changed; re-run setup once the fleet is provably quiet.", "error", ); return false; } // Whether a daemon was live at entry, captured before the first mutation: a // failed restart leg can stop that daemon without proving a replacement, so // only a run that entered with one must prove the runtime back before // dispatch may resume (#652). The capture lives INSIDE the transaction try // below so its refusal path is covered by the same `finally` that removes // the store snapshot — the byte-consistent capture the inventory just made // must never leak a stray temp copy in the state dir when the identity // cannot be proven (#650, PR #700 review #2). let hadLiveDaemonAtEntry = false; try { try { hadLiveDaemonAtEntry = apply.drain.daemonIdentity().running; } catch (err) { // The freeze was already written by this point, so it is released with // the refusal — an identity that cannot be proven must not leave a // setup-owned sentinel wedging dispatch for every project (review #2). restorePause(priorPause, ownedFence); ui.notify( `Setup stopped before writing anything: ${err instanceof Error ? err.message : String(err)}. ` + "The setup freeze was released; nothing has been changed; re-run setup once the fleet is provably quiet.", "error", ); return false; } openStore(dbPath()).close(); saveConfig(plan.nextConfig); briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined; runtimeFiles = writeHostRuntime(plan.runtime); for (const warning of runtimeFiles.warnings) ui.notify(warning, "warning"); // Re-prove before the smoke: the paused-daemon smoke is itself a dispatcher, // so it must never run under a reopened fence or beside a worker admitted // since the acknowledgement (review #6). The store/config/brief/runtime // writes above are rolled back with the whole inventory (review #652). const smokeReproof = barrierHolds(requireAck); if (smokeReproof !== undefined) { const restoreFailures = await restoreInventory(inventory, answers.trackerRepo, created, apply.deleteLabels); restorePause(priorPause, ownedFence); ui.notify( `Setup stopped before the daemon smoke: ${smokeReproof}. ` + (restoreFailures.length === 0 ? "The prior state was restored." : `Rollback could not fully restore the prior state: ${restoreFailures.join("; ")}`), "error", ); return false; } smoke = await withProgress("Running setup smoke", "Setup smoke ran", () => apply.smoke(plan.project.name), ); // Re-prove AFTER the awaited smoke too (review #2): the smoke is itself a // dispatcher, and a fence resumed while it ran — or a worker it admitted — // must stop the restart and heartbeat actions that follow. The restart, // if any, is the very next action, so this single re-proof covers it. const postSmokeReproof = barrierHolds(requireAck); if (postSmokeReproof !== undefined) { const restoreFailures = await restoreInventory(inventory, answers.trackerRepo, created, apply.deleteLabels); restorePause(priorPause, ownedFence); ui.notify( `Setup stopped after the daemon smoke: ${postSmokeReproof}. ` + (restoreFailures.length === 0 ? "The prior state was restored." : `Rollback could not fully restore the prior state: ${restoreFailures.join("; ")}`), "error", ); return false; } smokeLine = smoke.mode === "temporary" ? `paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` + `stored status for ${smoke.status.project}` : // An existing daemon (record or active unit) never ran a `--once` tick // and never staged a temporary one — the restart line below replaces // this one whenever the restart runs. `existing daemon; stored status for ${smoke.status.project}`; if (smoke.mode === "existing") { // The barrier acknowledged zero workers in every project under a proven // host-wide freeze before the first write, so a live worker here can only // mean the fence itself was bypassed. The old soft return — applied config // and a wedged setup pause left underneath that worker, with a "let them // finish" note — is the exact incident shape (#618) and is refused instead, // with the whole inventory rolled back (review #2, #652). if (smoke.status.liveWorkers > 0) { const restoreFailures = await restoreInventory(inventory, answers.trackerRepo, created, apply.deleteLabels); restorePause(priorPause, ownedFence); ui.notify( `${smoke.status.liveWorkers} live worker(s) appeared under the setup admission freeze — ` + "refusing to apply over a bypassed fence. " + (restoreFailures.length === 0 ? "The prior state was restored." : `Rollback could not fully restore the prior state: ${restoreFailures.join("; ")}`), "error", ); return false; } // The added-project contract (#650): the newly loaded runtime must be // proven to serve the added project BEFORE setup reports success — and // more generally, an "existing" smoke means a daemon is serving the // config that was just replaced, so it must reload the applied config // through the lifecycle seam. The deferral to a manual `restart --now` // an operator may never run left dispatch ready on a daemon that did // not serve the config it was just written, so it is gone: every // existing-mode apply restarts, even when the smoke proved health, and // the seam's `waitForOwnedDaemon` proves MainPID + /healthz + that the // (added) project is served before the apply reports success. const restarted = await apply.restart({ project: plan.project.name }); restartVia = restarted.via; smokeLine = `existing /healthz and stored status; restarted through ${restarted.via}; ` + `new /healthz on :${restarted.record.port}`; // The intentional restart is the generation boundary: from here the // fence protects the daemon we just started, not the one we began with. // Refresh the acknowledged world — pause re-proven, identity re-captured // — and drop the acknowledgement requirement, because a daemon this // apply itself started cannot have been mid-claim when the fence landed // (its first admission boundary is the tick gate, above routing); its // own first gate re-acknowledges on the next pass. A fence that vanished // while the restart ran (a concurrent resume) refuses here instead of // proceeding under an unproven freeze (review #3). const refreshedPause = apply.drain.pauseState(scope.pauseKey); if (refreshedPause === undefined) { const restoreFailures = await restoreInventory(inventory, answers.trackerRepo, created, apply.deleteLabels); restorePause(priorPause, ownedFence); ui.notify( "Setup stopped after the daemon restart: the setup pause was lifted while the daemon restarted, so " + "the acknowledged world no longer exists. " + (restoreFailures.length === 0 ? "The prior state was restored." : `Rollback could not fully restore the prior state: ${restoreFailures.join("; ")}`), "error", ); return false; } begun = { pauseToken: refreshedPause, daemon: apply.drain.daemonIdentity() }; requireAck = false; } // The tracker labels — before the heartbeat proof, so the deliberate // arm-failure hold (which keeps its writes by design) leaves the tracker // with the planned labels instead of a config whose routing keys and // queue label do not exist yet (#650, PR #700 review #3). Every rollback // path AFTER this point compensates them exactly through the inventory; // every failure path before it has created none. created = await withProgress("Creating tracker labels", "Tracker labels ready", () => apply.createLabels(answers.trackerRepo, plan.labels), ); if (plan.project.escalation.orchestrator === "external") { ui.notify( smoke.healthProven ? "Setup smoke passed. Proving the external heartbeat channel…" : "Setup smoke staged — the restart proved /healthz. Proving the external heartbeat channel…", "info", ); try { armLine = await apply.arm(plan.project.name, (line) => ui.notify(line, "info")); } catch (err) { ui.notify( [ "Setup files passed the paused daemon smoke, but the fleet remains held.", created.length > 0 ? `Tracker labels were created (${created.join(", ")}).` : "Planned labels already existed.", err instanceof Error ? err.message : String(err), `Start the external orchestrator in ${plan.project.workspaceRoot}, then run \`omp-conductor arm --project ${plan.project.name}\`.`, "After the arm proof succeeds, run `omp-conductor resume`.", ].join("\n"), "warning", ); ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info"); return false; } } // The final barrier boundary: the awaited label and heartbeat I/O are the // last steps before the commit, and a fence resumed while either ran — or // a worker admitted — must refuse with the tracker compensated and every // captured path restored. const finalReproof = barrierHolds(requireAck); if (finalReproof !== undefined) { const restoreFailures = await restoreInventory(inventory, answers.trackerRepo, created, apply.deleteLabels); restorePause(priorPause, ownedFence); ui.notify( `Setup stopped before committing: ${finalReproof}. ` + (restoreFailures.length === 0 ? "The prior state was restored." : `Rollback could not fully restore the prior state: ${restoreFailures.join("; ")}`), "error", ); return false; } } catch (err) { // Roll the whole apply back as one transaction (#652): every captured // path restored byte-for-byte (or removed on a first install), the // tracker compensated exactly, and — because a failed restart leg may // have stopped the entry daemon without proving a replacement — a healthy // daemon proven to serve the restored config before dispatch may resume. // If that proof cannot be made, stay fail-closed: a setup hold stays in // force with the recovery command named, and the thrown error reports the // first restoration failure honestly rather than implying the prior state // is coherent. // // The labels created before a mid-way createLabels throw ride on the // error (createMissingLabels attaches its partial list), so compensation // stays exact even when the tracker step itself failed. const createdLabels = created.length > 0 ? created : ((err as { created?: string[] }).created ?? []); const restoreFailures = await restoreInventory(inventory, answers.trackerRepo, createdLabels, apply.deleteLabels); let runtimeProved = !hadLiveDaemonAtEntry; if (hadLiveDaemonAtEntry) { // The proof targets the PRE-ENTRY configured set the rollback restored, // never the rejected generation's: the restarted runtime must be proven // to serve every project the prior runtime served (#650). runtimeProved = await apply.proveRuntime(inventory.priorProjects).catch(() => false); } // The pause ownership rule (#651 review #3): only the fence this // transaction froze is undone; a takeover stands. restorePause(priorPause, ownedFence); const failures = [...restoreFailures]; if (!runtimeProved) { // Fail-closed: only an entry that was actually unpaused needs a hold // re-asserted — an entry already held keeps that exact sentinel, and a // takeover that replaced the fence is preserved, never overwritten. // The re-asserted hold is ALWAYS the host-global sentinel, never a // project-scoped one: a fresh `paused-` written under a // failure that entered under a global pause is the silent fake this // slice exists to forbid — when the global hold later clears, the // hidden project pause would remain and deadlock the fleet (#650). if (priorPause.kind === "absent" && readSentinel(pausedPath(scope.pauseKey)).kind === "absent") { setPaused(true, { source: pauseSourceToken("setup"), reason: "setup apply failed and no healthy daemon could be proven", }); } failures.push( `no healthy daemon could be proven after the rollback — the fleet stays held; ` + `run \`omp-conductor restart --project ${plan.project.name}\` to recover`, ); } if (failures.length > 0) { throw new Error( `setup apply failed (${err instanceof Error ? err.message : String(err)}) and the prior state could not ` + `be fully restored: ${failures.join("; ")}`, ); } throw err; } finally { // The byte-consistent store snapshot exists only for this transaction. if (inventory.store.kind === "snapshot") rmSync(inventory.store.snapshotPath!, { force: true }); } // Lift the setup freeze and restore the exact pre-entry pause: an entry that // was already paused (an operator hold, say) keeps that sentinel byte for // byte, and an entry that was unpaused resumes dispatch exactly as the old // `resume` did. A takeover that replaced the setup fence meanwhile is // preserved, never overwritten (#651 review #3). restorePause(priorPause, ownedFence); ui.notify( [ created.length > 0 ? `Created label(s): ${created.join(", ")}` : "All required labels already existed.", `Wrote ${path}; dispatch is ready.`, briefPath === undefined ? "Kept the existing orchestrator brief." : `Wrote ${briefPath} + POLICY.md. Edit POLICY.md for Releases and Reporting.`, runtimeFiles.wrote.length === 0 ? "Host runtime files were already current." : `Wrote host runtime file(s): ${runtimeFiles.wrote.join(", ")}`, `Smoke ${smoke.healthProven ? "passed" : "staged; the restart proved /healthz"}: ${smokeLine}.`, `Heartbeat: ${armLine}.`, "", "Use the documented toy-issue drill to prove one complete worker path.", "", // The one grooming recommendation setup makes, and it is only a sentence // (#827). Deliberately inert: nothing above or below this line installs, // probes, version-checks or refuses on it, because a conductor guarantee // must never depend on prose that releases on somebody else's cadence // (#506's judgement). Printed only here, on the committed success path, // so a failed or cancelled apply can never imply setup finished. "Optional: the spec-out skills pack at github.com/mattpocock/skills (`npx skills`) adds a", "grooming workflow this fleet has no opinion about — `to-spec` / `to-tickets` for turning a", "rough idea into sized slices, and `grill-me` for the questions worth asking before work", "starts. Nothing was installed or checked just now, and conductor is fully functional", "without it: skip it freely.", ].join("\n"), "info", ); // The two installs setup used to only print for the operator to retype. // Offered rather than assumed: each needs root, and an operator who wants to // read the unit before it is enabled must be able to decline and run the verb // later. `undefined` — a dismissed prompt — is a skip, not a cancel: the config // is already written by this point, so there is nothing left to abandon. // // Gated on `installedAction`, never on `service.action`. The staged copy is // written by this very run, so it is always current by now — every fleet // configured before the install was executed would have been told the unit // matched and never offered the install, which is precisely the population that // has never installed one. if (plan.runtime.installedAction === "keep") { ui.notify(`The unit systemd reads (${plan.runtime.installedPath}) already matches this config.`, "info"); } else if (platform() !== "linux") { // No systemctl to run. The staged file is real and is what an operator copies // to the box that will run it, so say where it is rather than offer an install // that could only refuse. ui.notify( `Staged the unit at ${plan.runtime.service.path}. systemd install is Linux-only — copy it to the fleet host and run \`omp-conductor setup host\` there.`, "info", ); } else { const install = await ui.confirm( "Install and start the supervised daemon now?", `${plan.runtime.installedAction === "create" ? "Installs" : "Updates"} ${plan.runtime.installedPath} from ` + `${plan.runtime.service.path}, then enables and restarts it. Needs root, one step at a time, and shows every ` + "command before it runs. Skipping is fine — `omp-conductor setup host` does exactly this later.", { key: `install-daemon.${plan.project.name}` }, ); if (install === true) await apply.hostInstall( plan.project, resolveCaps(plan.project, plan.nextConfig.defaults), telegram.stateDir, ui, ); else ui.notify("Left the unit staged. When you want it supervised: omp-conductor setup host", "info"); } await offerCodeGraph(plan.project, ui, apply.graphInstall); // Always print: first install, amend, and add-a-project all need the operator // to create/verify the herdr pane. Added projects especially — the CLI wrote // tick + config but cannot start a herdr agent (#319). ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info"); return true; }