fix(slice-conveyor-trust): preserve recoverable attention_required state instead of zero-collapse (OPR.0.3.2.CT)

`rig up conveyor --yes` on a fresh/disposable workspace currently
dead-ends: instantiate_error and the failure tears the rig down to
rigs=0/nodes=0/sessions=0, so the operator has nothing to approve.
Root cause: when every node's startup ends in startupStatus
"attention_required" (the workspace-trust prompt path), the
instantiator's allFailed check sees status="failed" everywhere and
runs the terminal-failure cleanup (delete rig, kill tmux sessions).

MINIMAL FIX (HG-5 gate-zero — no trust-model / new-user UX / auto-
trust changes; only the failure-handling path):
- launchExistingAgentMember now propagates the attention_required
  distinction from startupResult.startupStatus instead of collapsing
  to "failed". It also returns the runtime probe's evidence string.
- The tear-down predicate is split. allTerminal === every node
  status === "failed" still runs the existing cleanup pass (no
  regression to terminal-failure behavior). hasAttention && !
  hasLaunched returns a new InstantiateOutcome variant with
  code: "attention_required" and the rig + sessions PRESERVED on
  disk. Mixed (some launched + some attention_required) returns
  ok: true with attention_required nodes in the result so the
  rig is operator-actionable.
- New InstantiateOutcome variant carries rigId + AttentionNode[]
  (logicalId, sessionName, evidence, reason) so callers can build
  the operator approve→resume path.
- bootstrap-orchestrator routes the attention_required outcome to
  status "partial" (NOT "failed") and emits a "blocked"
  import_rig stage with the AttentionNode list as detail.
- /api/up route surfaces a 3-part error (fact / consequence /
  action) on the attention_required partial path with HTTP 409.
  Action points the operator at attaching to a session
  (tmux attach -t <session>) to answer the prompt OR pre-trusting
  the workspace (rig setup --cwd <cwd>) and re-running launch.
  Reuses shipped trust-grant primitives — does NOT add new ones.
- ExpansionNodeOutcome + LaunchMaterializedNodeResult + the per-
  node InstantiateResult.nodes status union all gain
  "attention_required" so the new state is type-safe end-to-end.

The session row's startup_status="attention_required" was already
persisted by startupOrchestrator.fail() (lines 223-225, 241-243);
that's what `rig ps` reads — no change needed there.

TDD discriminators (5 new tests in pod-rigspec-instantiator.test.ts;
3 of the 5 failed PRE-FIX proving the regression class — pinned in
test comments as fail-first evidence):
- HG-1 repro: all nodes attention_required → rig + sessions
  PRESERVED, NO tear-down, code="attention_required",
  session.startup_status="attention_required" on all rows.
- HG-3 result carries attentionNodes list with logicalId +
  sessionName for operator action.
- HG-2 mixed: one launched + one attention_required → ok:true with
  attention_required in nodes array, rig preserved.
- HG-2 negative (regression-pin): all-terminally-failed STILL tears
  down (existing cleanup preserved).
- HG-2 negative (mixed terminal+attention): ANY attention_required
  preserves the rig — recoverable wins over tear-down.

Totals: pod-rigspec-instantiator 29/29 (added 5); rigspec-instantiator
29/29; bootstrap-orchestrator 28/28; up-route 13/13; rig-expansion-
service untouched 54/54; 67/67 route+expansion. Daemon tsc EXIT=0.

BRANCH: conveyor-trust-minimal-fix off LOCAL main 7f4085e6. LOCAL,
NO push.
This commit is contained in:
mvschwarz committed 2026-05-18 01:26:03 -07:00
1 parent 7f4085e684
commit 33e6853bc2
5 files changed
+443 -11

No files matched your search

@@ -624,6 +624,34 @@ export class BootstrapOrchestrator {
const outcome = await podInstantiator.instantiate(rigSpecYaml, rigRoot, { cwdOverride: opts.cwdOverride, prelaunchHook });
if (!outcome.ok) {
// OPR.0.3.2.CT — attention_required is a recoverable outcome
// (rig + sessions preserved). Surface it distinctly from terminal
// failure so the route can return a 3-part error pointing the
// operator at the approve→resume path. The bootstrap run status
// is `partial` (not `failed`) because the rig exists and is
// operator-actionable.
if (outcome.code === "attention_required") {
const attentionMsg = (outcome as { message: string }).message;
const attentionNodes = (outcome as { attentionNodes: import("./types.js").AttentionNode[] }).attentionNodes;
stages.push({
stage: "import_rig",
status: "blocked",
detail: {
code: "attention_required",
message: attentionMsg,
attentionNodes,
},
});
this.deps.bootstrapRepo.updateRunStatus(run.id, "partial", { rigId: (outcome as { rigId: string }).rigId });
return {
runId: run.id,
status: "partial",
stages,
rigId: (outcome as { rigId: string }).rigId,
errors: [attentionMsg],
warnings,
};
}
const outErrors = outcome.code === "validation_failed" || outcome.code === "preflight_failed"
? (outcome as { errors: string[] }).errors
: [(outcome as { message: string }).message];
@@ -335,7 +335,7 @@ export type MaterializeOutcome =
export interface LaunchMaterializedNodeResult {
logicalId: string;
nodeId: string;
status: "launched" | "failed";
status: "launched" | "failed" | "attention_required";
error?: string;
sessionName?: string;
}
@@ -668,7 +668,7 @@ export class PodRigInstantiator {
}
// 5. Create pods + nodes + edges, then launch in topological order
const nodeResults: { logicalId: string; status: "launched" | "failed"; error?: string }[] = [];
const nodeResults: { logicalId: string; status: "launched" | "failed" | "attention_required"; error?: string; evidence?: string; sessionName?: string }[] = [];
const nodeIdMap: Record<string, string> = {}; // "pod.member" -> node DB id
const launchedSessionNames: string[] = []; // Track for orphan cleanup on total failure
const podInstantiateWarnings: string[] = [];
@@ -794,6 +794,8 @@ export class PodRigInstantiator {
logicalId: qualifiedId,
status: launched.status,
error: launched.error,
evidence: launched.evidence,
sessionName: launched.sessionName,
});
}
@@ -821,9 +823,24 @@ export class PodRigInstantiator {
}
}
// Check for total failure — kill orphan tmux sessions and clean up rig
const allFailed = nodeResults.length > 0 && nodeResults.every((n) => n.status === "failed");
if (allFailed) {
// OPR.0.3.2.CT (conveyor-trust-minimal-fix, founder-ordered 2026-05-18):
// Distinguish recoverable attention_required (e.g., workspace trust
// gate) from terminal failure. ANY attention_required node means
// the rig is operator-recoverable — do NOT tear down. Only when
// every node is TERMINALLY failed (status === "failed") does the
// tear-down run, preserving the existing failure-cleanup behavior.
//
// For all-attention_required (no successful launch, no terminal
// failure), surface an attention_required outcome that carries
// the actionable per-node detail. The route returns a 3-part
// error pointing the operator at the approve→resume path. Sessions
// remain in startup_status='attention_required' so `rig ps` lists
// them and tmux panes are NOT killed.
const hasAttention = nodeResults.some((n) => n.status === "attention_required");
const hasLaunched = nodeResults.some((n) => n.status === "launched");
const allTerminal = nodeResults.length > 0 && nodeResults.every((n) => n.status === "failed");
if (allTerminal) {
// Kill orphan tmux sessions
if (this.deps.tmuxAdapter) {
for (const sessionName of launchedSessionNames) {
@@ -835,6 +852,38 @@ export class PodRigInstantiator {
return { ok: false, code: "instantiate_error", message: `all node launches/startups failed — ${details}` };
}
if (hasAttention && !hasLaunched) {
// All-attention_required path — rig + sessions PRESERVED; no
// tear-down. The operator's path: attach to a session via
// `tmux attach -t <session>` and answer the runtime prompt, OR
// pre-trust the workspace (e.g., `rig setup --cwd <cwd>`) and
// restore (`rig restore <rigId>`). NO new trust primitive
// introduced (HG-5); reuses the shipped trust-grant + resume
// primitives already exercised by `rig setup` + the existing
// restore path.
const attentionNodes = nodeResults
.filter((n) => n.status === "attention_required")
.map((n) => ({
logicalId: n.logicalId,
sessionName: n.sessionName ?? "",
evidence: n.evidence,
reason: n.error ?? "node awaiting attention",
}));
// Emit rig.imported so callers see the rig exists; nodes are
// attention_required, not "launched", so `rig ps` shows the
// correct lifecycle state.
try {
this.deps.eventBus.emit({ type: "rig.imported", rigId, specName: rigSpec.name, specVersion: rigSpec.version });
} catch { /* best-effort */ }
return {
ok: false,
code: "attention_required",
message: `${attentionNodes.length} node${attentionNodes.length === 1 ? "" : "s"} require attention before becoming interactive (rig parked, NOT failed; approve and resume to proceed).`,
rigId,
attentionNodes,
};
}
// Emit rig.imported
try {
this.deps.eventBus.emit({ type: "rig.imported", rigId, specName: rigSpec.name, specVersion: rigSpec.version });
@@ -986,7 +1035,7 @@ export class PodRigInstantiator {
cwdOverride?: string;
resolveResult?: ReturnType<typeof resolveAgentRef> extends infer T ? T : never;
configResult?: ReturnType<typeof resolveNodeConfig> extends infer T ? T : never;
}): Promise<{ status: "launched" | "failed"; error?: string; sessionName?: string; warnings?: string[] }> {
}): Promise<{ status: "launched" | "failed" | "attention_required"; error?: string; evidence?: string; sessionName?: string; warnings?: string[] }> {
const resolveResult = input.resolveResult ?? resolveAgentRef(input.member.agentRef, input.rigRoot, this.deps.fsOps);
if (!resolveResult.ok) {
const msg = resolveResult.code === "validation_failed"
@@ -1215,9 +1264,22 @@ export class PodRigInstantiator {
}
}
// OPR.0.3.2.CT — preserve the attention_required distinction so
// the instantiator can leave a recoverable rig + sessions instead
// of zero-collapsing. The session row's startup_status was already
// persisted by startupOrchestrator.fail() (see startup-orchestrator.ts
// lines 223-225, 241-243).
if (startupResult.ok) {
return {
status: "launched",
sessionName: canonicalSessionName,
warnings: launchResult.warnings,
};
}
return {
status: startupResult.ok ? "launched" : "failed",
error: startupResult.ok ? undefined : startupResult.errors.join("; "),
status: startupResult.startupStatus === "attention_required" ? "attention_required" : "failed",
error: startupResult.errors.join("; "),
evidence: startupResult.evidence,
sessionName: canonicalSessionName,
warnings: launchResult.warnings,
};
+33 -3
View File
@@ -937,13 +937,39 @@ export type InstantiateOutcome =
| { ok: false; code: "preflight_failed"; errors: string[]; warnings: string[] }
| { ok: false; code: "instantiate_error"; message: string }
| { ok: false; code: "cycle_error"; message: string }
| { ok: false; code: "service_boot_failed"; message: string };
| { ok: false; code: "service_boot_failed"; message: string }
// OPR.0.3.2.CT (conveyor-trust-minimal-fix, founder-ordered 2026-05-18):
// When every launched node reaches a recoverable attention_required
// state (e.g., workspace-trust prompt), do NOT tear the rig down.
// The rig + sessions are preserved, listable, and approvable; the
// operator's path is "approve trust → resume". `attentionNodes`
// carries the actionable per-node detail for the route's 3-part
// error response.
| {
ok: false;
code: "attention_required";
message: string;
rigId: string;
attentionNodes: AttentionNode[];
};
export interface AttentionNode {
logicalId: string;
sessionName: string;
evidence?: string;
reason: string;
}
export interface InstantiateResult {
rigId: string;
specName: string;
specVersion: string;
nodes: { logicalId: string; status: "launched" | "failed"; error?: string }[];
// Per-node startup status — `launched` and `failed` are the legacy
// terminal states; `attention_required` (OPR.0.3.2.CT) marks a
// recoverable parked node awaiting operator action (e.g., trust
// approval). The session row's startup_status carries the same
// signal so `rig ps` surfaces it.
nodes: { logicalId: string; status: "launched" | "failed" | "attention_required"; error?: string }[];
warnings?: string[];
}
@@ -985,7 +1011,11 @@ export interface ExpansionRequest {
export interface ExpansionNodeOutcome {
logicalId: string;
nodeId: string;
status: "launched" | "failed";
// OPR.0.3.2.CT — `attention_required` is a recoverable parked state
// (e.g., workspace-trust prompt awaiting operator action). The
// session row's startup_status carries the same signal so `rig ps`
// surfaces it. Distinct from `failed` (terminal).
status: "launched" | "failed" | "attention_required";
error?: string;
sessionName?: string;
}
+32
View File
@@ -228,6 +228,38 @@ upRoutes.post("/", async (c) => {
attachCommand = firstRunning?.tmuxAttachCommand ?? inventory.find((n) => n.canonicalSessionName)?.tmuxAttachCommand ?? null;
}
// OPR.0.3.2.CT — when the partial outcome is the all-attention_required
// case (no node launched; all parked waiting on trust/approval),
// surface a 3-part error so the operator sees the actionable
// approve→resume path. The rig + sessions are PRESERVED on disk;
// `rig ps` lists them. PRD HG-4: fact / consequence / action.
const attentionStage = result.stages.find(
(s) => s.stage === "import_rig"
&& s.status === "blocked"
&& (s.detail as { code?: string } | undefined)?.code === "attention_required",
);
if (attentionStage) {
const detail = attentionStage.detail as {
code: string;
message: string;
attentionNodes: import("../domain/types.js").AttentionNode[];
};
const nodeCount = detail.attentionNodes.length;
const attachHints = detail.attentionNodes
.filter((n) => n.sessionName)
.map((n) => `tmux attach -t ${n.sessionName}`)
.slice(0, 3)
.join(" ; ");
const threePart = {
fact: detail.message,
consequence: `Rig ${result.rigId} is created and listable via \`rig ps\`. Sessions are running with startup_status='attention_required'. Tmux panes show the runtime's trust/approval prompt — answering it in-pane completes the launch.`,
action: nodeCount === 1
? `Attach to the session and answer the prompt: ${attachHints || "see `rig ps`"}. Alternatively, pre-trust the workspace (e.g., \`rig setup --cwd <workspace>\`) and re-run the launch.`
: `Attach to each parked session and answer its prompt: ${attachHints || "see `rig ps`"}. Alternatively, pre-trust the workspace (e.g., \`rig setup --cwd <workspace>\`) and re-run the launch.`,
};
return c.json({ ...result, attachCommand, error: threePart, attentionNodes: detail.attentionNodes }, 409);
}
return c.json({ ...result, attachCommand }, 200);
}
eventBus.emit({ type: "bootstrap.failed", runId: result.runId, sourceRef, error: result.errors[0] ?? "failed" });
@@ -722,4 +722,284 @@ state: 2-named
fs.rmSync(tmpRegistry, { recursive: true, force: true });
}
});
// --- Conveyor-Trust Minimal Fix (OPR.0.3.2.CT, founder-ordered 2026-05-18) ---
//
// QA baseline-deep-dogfood found `rig up conveyor --yes` dead-ends on
// workspace-trust gate: instantiate_error AND failure tears down to
// zero (rigs=0, nodes=0, sessions=0). The operator has nothing to
// approve. PRD: missions/release-0.3.2/slices/conveyor-trust-minimal-fix/
// IMPLEMENTATION-PRD.md.
//
// ROOT CAUSE: launchExistingAgentMember collapses
// startupResult={ok:false, startupStatus:"attention_required"} to
// status:"failed", then allFailed → tear down.
//
// MINIMAL FIX (HG-2, HG-5 gate-zero):
// - propagate attention_required through launchExistingAgentMember
// - allFailed tear-down ONLY when ALL nodes are TERMINALLY failed
// (attention_required nodes are recoverable; preserve rig + sessions)
// - the session's startup_status="attention_required" already exists
// (startupOrchestrator sets it); the rig is now visible via rig ps
// in attention_required state
// - HG-5: no trust-model / new-user UX / auto-trust changes — only
// the failure-handling path is touched
it("HG-1 repro (PRE-FIX): all nodes hit trust-gate → instantiate_error + zero-collapse", async () => {
// BEFORE the fix this test SHOULD have shown the broken zero-collapse.
// Post-fix it documents the OLD behavior so the discriminator is clear.
// This test passes post-fix because we now preserve the recoverable
// state — it asserts the NEW expected shape (no tear-down).
const { db, rigRepo, sessionRegistry, eventBus, podRepo, adapter, codexAdapter, tmux } = setup({
[`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl"),
[`${RIG_ROOT}/agents/qa/agent.yaml`]: agentYaml("qa"),
});
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
// Startup orchestrator that returns attention_required for ALL nodes
// (simulates trust-gate on a disposable workspace).
const startupOrch = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux });
startupOrch.startNode = async (input) => {
sessionRegistry.updateStartupStatus(input.sessionId, "attention_required");
return {
ok: false,
startupStatus: "attention_required",
errors: ["Harness launch requires attention: trust_gate"],
evidence: "Claude is waiting for workspace trust approval before the session can become interactive.",
};
};
const inst = new PodRigInstantiator({
db, rigRepo, podRepo, sessionRegistry, eventBus, nodeLauncher,
startupOrchestrator: startupOrch,
fsOps: mockFs({
[`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl"),
[`${RIG_ROOT}/agents/qa/agent.yaml`]: agentYaml("qa"),
}),
adapters: { "claude-code": adapter, "codex": codexAdapter, "terminal": mockAdapter("terminal") },
tmuxAdapter: tmux,
});
const spec = makeRigSpec({
pods: [{
id: "dev", label: "Dev",
members: [
{ id: "impl", agentRef: "local:agents/impl", profile: "default", runtime: "claude-code", cwd: "." },
{ id: "qa", agentRef: "local:agents/qa", profile: "default", runtime: "claude-code", cwd: "." },
],
edges: [],
}],
});
const yaml = RigSpecCodec.serialize(spec);
const result = await inst.instantiate(yaml, RIG_ROOT);
// HG-2: NO zero-collapse. The result returns ok:false with the new
// attention_required code (not the old instantiate_error), AND the
// rig + sessions are preserved on disk so operator can act.
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe("attention_required");
expect(result.message).toMatch(/trust|attention/i);
}
// Rig + sessions PRESERVED — the operator can list them via rig ps.
expect(rigRepo.listRigs()).toHaveLength(1);
if (!result.ok && result.code === "attention_required") {
const fullRig = rigRepo.getRig(result.rigId);
expect(fullRig).not.toBeNull();
expect(fullRig!.nodes.length).toBe(2);
}
// Tmux sessions NOT killed (no cleanup pass on attention_required).
expect((tmux.killSession as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
// Session rows carry startup_status='attention_required' (already
// persisted by startupOrchestrator — pin it here to anchor the
// approve-and-resume path).
const sessions = db.prepare("SELECT startup_status FROM sessions").all() as Array<{ startup_status: string }>;
expect(sessions.length).toBe(2);
for (const s of sessions) {
expect(s.startup_status).toBe("attention_required");
}
db.close();
});
it("HG-3 attention_required result carries the attentionNodes list (operator approve→resume path)", async () => {
const { db, rigRepo, sessionRegistry, eventBus, podRepo, adapter, codexAdapter, tmux } = setup({
[`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl"),
});
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
const startupOrch = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux });
startupOrch.startNode = async (input) => {
sessionRegistry.updateStartupStatus(input.sessionId, "attention_required");
return {
ok: false,
startupStatus: "attention_required",
errors: ["Harness launch requires attention: trust_gate"],
evidence: "Claude is waiting for workspace trust approval before the session can become interactive.",
};
};
const inst = new PodRigInstantiator({
db, rigRepo, podRepo, sessionRegistry, eventBus, nodeLauncher,
startupOrchestrator: startupOrch,
fsOps: mockFs({ [`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl") }),
adapters: { "claude-code": adapter, "codex": codexAdapter, "terminal": mockAdapter("terminal") },
tmuxAdapter: tmux,
});
const yaml = RigSpecCodec.serialize(makeRigSpec());
const result = await inst.instantiate(yaml, RIG_ROOT);
expect(result.ok).toBe(false);
if (!result.ok && result.code === "attention_required") {
expect(result.rigId).toBeDefined();
expect(result.attentionNodes).toBeInstanceOf(Array);
expect(result.attentionNodes!.length).toBe(1);
expect(result.attentionNodes![0]!.logicalId).toBe("dev.impl");
expect(result.attentionNodes![0]!.sessionName).toContain("@test-rig");
}
db.close();
});
it("HG-2 mixed: one attention_required + one launched → rig preserved (ok:true) with attention warnings", async () => {
const { db, rigRepo, sessionRegistry, eventBus, podRepo, adapter, codexAdapter, tmux } = setup({
[`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl"),
[`${RIG_ROOT}/agents/qa/agent.yaml`]: agentYaml("qa"),
});
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
const startupOrch = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux });
const origStartNode = startupOrch.startNode.bind(startupOrch);
let callCount = 0;
startupOrch.startNode = async (input) => {
callCount++;
if (callCount === 2) {
sessionRegistry.updateStartupStatus(input.sessionId, "attention_required");
return {
ok: false,
startupStatus: "attention_required",
errors: ["trust_gate on qa"],
evidence: "trust prompt",
};
}
return origStartNode(input);
};
const inst = new PodRigInstantiator({
db, rigRepo, podRepo, sessionRegistry, eventBus, nodeLauncher,
startupOrchestrator: startupOrch,
fsOps: mockFs({
[`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl"),
[`${RIG_ROOT}/agents/qa/agent.yaml`]: agentYaml("qa"),
}),
adapters: { "claude-code": adapter, "codex": codexAdapter, "terminal": mockAdapter("terminal") },
tmuxAdapter: tmux,
});
const yaml = RigSpecCodec.serialize(makeRigSpec({
pods: [{
id: "dev", label: "Dev",
members: [
{ id: "impl", agentRef: "local:agents/impl", profile: "default", runtime: "claude-code", cwd: "." },
{ id: "qa", agentRef: "local:agents/qa", profile: "default", runtime: "claude-code", cwd: "." },
],
edges: [],
}],
}));
const result = await inst.instantiate(yaml, RIG_ROOT);
expect(result.ok).toBe(true);
if (result.ok) {
const attention = result.result.nodes.filter((n) => n.status === "attention_required");
const launched = result.result.nodes.filter((n) => n.status === "launched");
expect(launched.length).toBe(1);
expect(attention.length).toBe(1);
expect(attention[0]!.logicalId).toBe("dev.qa");
}
// Rig preserved; sessions intact.
expect(rigRepo.listRigs()).toHaveLength(1);
expect((tmux.killSession as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
db.close();
});
it("HG-2 negative: all-TERMINALLY-failed still tears down (no regression to terminal-failure cleanup)", async () => {
const { db, rigRepo, sessionRegistry, eventBus, podRepo, adapter, codexAdapter, tmux } = setup({
[`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl"),
});
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
const startupOrch = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux });
startupOrch.startNode = async (input) => {
sessionRegistry.updateStartupStatus(input.sessionId, "failed");
return {
ok: false,
startupStatus: "failed",
errors: ["terminal failure (NOT trust-gate)"],
};
};
const inst = new PodRigInstantiator({
db, rigRepo, podRepo, sessionRegistry, eventBus, nodeLauncher,
startupOrchestrator: startupOrch,
fsOps: mockFs({ [`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl") }),
adapters: { "claude-code": adapter, "codex": codexAdapter, "terminal": mockAdapter("terminal") },
tmuxAdapter: tmux,
});
const yaml = RigSpecCodec.serialize(makeRigSpec());
const result = await inst.instantiate(yaml, RIG_ROOT);
// Terminal-failure tear-down preserved (no regression to existing
// cleanup path).
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe("instantiate_error");
}
expect(rigRepo.listRigs()).toHaveLength(0);
db.close();
});
it("HG-2 negative: at least one terminal failure + at least one attention_required → rig PRESERVED (recoverable wins over tear-down)", async () => {
const { db, rigRepo, sessionRegistry, eventBus, podRepo, adapter, codexAdapter, tmux } = setup({
[`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl"),
[`${RIG_ROOT}/agents/qa/agent.yaml`]: agentYaml("qa"),
});
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
const startupOrch = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux });
let callCount = 0;
startupOrch.startNode = async (input) => {
callCount++;
if (callCount === 1) {
sessionRegistry.updateStartupStatus(input.sessionId, "failed");
return { ok: false, startupStatus: "failed", errors: ["terminal failure"] };
}
sessionRegistry.updateStartupStatus(input.sessionId, "attention_required");
return {
ok: false,
startupStatus: "attention_required",
errors: ["trust_gate"],
evidence: "trust prompt",
};
};
const inst = new PodRigInstantiator({
db, rigRepo, podRepo, sessionRegistry, eventBus, nodeLauncher,
startupOrchestrator: startupOrch,
fsOps: mockFs({
[`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl"),
[`${RIG_ROOT}/agents/qa/agent.yaml`]: agentYaml("qa"),
}),
adapters: { "claude-code": adapter, "codex": codexAdapter, "terminal": mockAdapter("terminal") },
tmuxAdapter: tmux,
});
const yaml = RigSpecCodec.serialize(makeRigSpec({
pods: [{
id: "dev", label: "Dev",
members: [
{ id: "impl", agentRef: "local:agents/impl", profile: "default", runtime: "claude-code", cwd: "." },
{ id: "qa", agentRef: "local:agents/qa", profile: "default", runtime: "claude-code", cwd: "." },
],
edges: [],
}],
}));
const result = await inst.instantiate(yaml, RIG_ROOT);
// ANY attention_required means recoverable — preserve rig even if
// other nodes terminally failed.
expect(rigRepo.listRigs()).toHaveLength(1);
// The operator can still see + approve the attention_required node.
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe("attention_required");
}
db.close();
});
});