mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 03:31:40 -10:00
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:
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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user