merge: conveyor-trust minimal recoverable-state fix (release-0.3.2, founder-ordered)

Founder-ordered minimal fix: rig up conveyor --yes no longer dead-ends
first-run/disposable users. A trust-gated launch failure now PARKS a
recoverable, listable attention_required state (rig + sessions + the
attentionNodes preserved, 3-part fact/consequence/action error) instead
of collapsing to zero; the operator grants trust via the existing
primitive and the parked launch proceeds to interactive — proven
end-to-end for both Claude and Codex nodes.

HG-5 gate-zero held THROUGHOUT (incl. the QA-fix loop): no
workspace-trust-model / workspaceTrustKeys / provisionWorkspaceTrust /
auto-trust / new-user-UX / endpoint / migration / event-type change;
only the failure-handling path. The full new-user conveyor onboarding
stays deferred to release-0.3.3 / slice-04.

driver (redo-driver-2) -> redo-guard-2 CLEAR @ 33547403 (3-round loop)
-> velocity-qa HG-8 adversarial dogfood found+fixed 3 guidance/Codex-
readiness issues -> redo-guard-2 re-review CLEAR @ 5264b19a (post-QA-fix
delta re-gated per verify-don't-relay). Orch heavy pre-merge verify
passed (tip==guard==qa-cleared 5264b19a, 0-after, base 7f4085e6
unmoved, no conflicts, HG-5 grep-clean, evidence present). Local main
only -- no push. Merged via isolated worktree to not disturb the
in-flight slice-05 checkout.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-18 02:16:11 -07:00
commit dc57b1e0c1
12 files changed
+1142 -38

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];
@@ -635,13 +663,49 @@ export class BootstrapOrchestrator {
const result = outcome.result;
const anyFailed = result.nodes.some((n) => n.status === "failed");
const finalStatus: BootstrapStatus = anyFailed ? "partial" : "completed";
const anyAttention = result.nodes.some((n) => n.status === "attention_required");
stages.push({
stage: "import_rig",
status: anyFailed ? "failed" : "ok",
detail: { rigId: result.rigId, specName: result.specName, nodes: result.nodes },
});
// OPR.0.3.2.CT (guard verdict qitem-20260518082933 BLOCKER 1):
// attention_required nodes are recoverable but NOT done — the
// launch is partial and the operator needs the same approve→resume
// surface the all-attention path already gets. Treating mixed
// launched+attention as "completed" would hide the parked seat
// behind a 201 success response. finalStatus is partial whenever
// any node is failed OR attention_required; the import_rig stage
// is "blocked" (not "ok") with attentionNodes detail so the route
// can build the 3-part error from the same path the all-attention
// case uses.
const finalStatus: BootstrapStatus = anyFailed || anyAttention ? "partial" : "completed";
if (anyAttention) {
const attentionNodes: import("./types.js").AttentionNode[] = result.nodes
.filter((n) => n.status === "attention_required")
.map((n) => ({
logicalId: n.logicalId,
sessionName: n.sessionName ?? "",
evidence: n.evidence,
reason: n.error ?? "node awaiting attention",
}));
const message = `${attentionNodes.length} node${attentionNodes.length === 1 ? "" : "s"} require attention before becoming interactive (rig parked, NOT failed; approve and resume to proceed).`;
stages.push({
stage: "import_rig",
status: "blocked",
detail: {
code: "attention_required",
message,
rigId: result.rigId,
specName: result.specName,
nodes: result.nodes,
attentionNodes,
},
});
} else {
stages.push({
stage: "import_rig",
status: anyFailed ? "failed" : "ok",
detail: { rigId: result.rigId, specName: result.specName, nodes: result.nodes },
});
}
if (result.warnings?.length) {
warnings.push(...result.warnings);
}
+31 -12
View File
@@ -26,6 +26,10 @@ interface ClaudeContextProvisioner {
checkReady?(binding: NodeBinding): Promise<ReadinessResult>;
}
interface RuntimeReadinessChecker {
checkReady?(binding: NodeBinding): Promise<ReadinessResult>;
}
/**
* Polls known managed runtime context sources and persists the latest
* normalized telemetry. Scheduler-only: no queries, no response shaping,
@@ -41,6 +45,7 @@ export class ContextMonitor {
private store: ContextUsageStore;
private claudeContextProvisioner: ClaudeContextProvisioner | null;
private compactionEnforcer: ClaudeCompactionEnforcer | null;
private readinessCheckers: Record<string, RuntimeReadinessChecker | undefined>;
private timer: ReturnType<typeof setInterval> | null = null;
constructor(
@@ -48,11 +53,16 @@ export class ContextMonitor {
store: ContextUsageStore,
claudeContextProvisioner?: ClaudeContextProvisioner,
compactionEnforcer?: ClaudeCompactionEnforcer,
readinessCheckers?: Record<string, RuntimeReadinessChecker | undefined>,
) {
this.db = db;
this.store = store;
this.claudeContextProvisioner = claudeContextProvisioner ?? null;
this.compactionEnforcer = compactionEnforcer ?? null;
this.readinessCheckers = readinessCheckers ?? {};
if (claudeContextProvisioner && !this.readinessCheckers["claude-code"]) {
this.readinessCheckers["claude-code"] = claudeContextProvisioner;
}
}
/** Discover active managed Claude sessions and poll their sidecar files. */
@@ -60,15 +70,18 @@ export class ContextMonitor {
const sessions = this.getEligibleSessions();
for (const session of sessions) {
let observed: ContextUsage | null = null;
try {
observed = this.readContextUsage(session);
this.store.persist(session.node_id, observed);
} catch {
observed = null;
// One bad session must not crash polling for others
const canReadContextUsage = session.runtime !== "codex" || !!session.resume_token;
if (canReadContextUsage) {
try {
this.store.persist(session.node_id, this.store.unknownUsage("parse_error"));
} catch { /* give up on this session */ }
observed = this.readContextUsage(session);
this.store.persist(session.node_id, observed);
} catch {
observed = null;
// One bad session must not crash polling for others
try {
this.store.persist(session.node_id, this.store.unknownUsage("parse_error"));
} catch { /* give up on this session */ }
}
}
await this.normalizeStartupStatus(session);
@@ -139,7 +152,13 @@ export class ContextMonitor {
LEFT JOIN bindings b ON b.node_id = n.id
WHERE (
(n.runtime = 'claude-code' AND s.status = 'running')
OR (n.runtime = 'codex' AND s.resume_token IS NOT NULL)
OR (
n.runtime = 'codex'
AND (
s.resume_token IS NOT NULL
OR s.startup_status IN ('failed', 'attention_required')
)
)
)
AND COALESCE(b.attachment_type, 'tmux') = 'tmux'
AND COALESCE(b.tmux_session, s.session_name) IS NOT NULL
@@ -162,12 +181,12 @@ export class ContextMonitor {
}
private async normalizeStartupStatus(session: EligibleSession): Promise<void> {
if (session.runtime !== "claude-code") return;
if (!this.claudeContextProvisioner?.checkReady) return;
const checker = session.runtime ? this.readinessCheckers[session.runtime] : undefined;
if (!checker?.checkReady) return;
if (session.startup_status !== "failed" && session.startup_status !== "attention_required") return;
try {
const readiness = await this.claudeContextProvisioner.checkReady({
const readiness = await checker.checkReady({
id: `context-monitor:${session.session_id}`,
nodeId: session.node_id,
attachmentType: "tmux",
@@ -126,13 +126,6 @@ export function assessNativeResumeProbe(
detail: "Codex could not refresh the stored access token; an operator must sign in again before the session can resume.",
};
}
if (looksLikeCodexTrustPrompt(paneContent)) {
return {
status: "inconclusive",
code: "trust_gate",
detail: "Codex is waiting for workspace trust approval before the session can become interactive.",
};
}
if (looksLikeCodexModelSelectionPrompt(paneContent)) {
return {
status: "inconclusive",
@@ -147,6 +140,13 @@ export function assessNativeResumeProbe(
detail: "Codex is running with an active interactive TUI in the probe pane.",
};
}
if (looksLikeCodexTrustPrompt(paneContent)) {
return {
status: "inconclusive",
code: "trust_gate",
detail: "Codex is waiting for workspace trust approval before the session can become interactive.",
};
}
if (paneContent.includes("Update available!") || paneContent.includes("Updating Codex")) {
return {
status: "inconclusive",
@@ -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):
// 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,14 +852,56 @@ 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. NO
// new trust primitive
// introduced (HG-5); reuses the runtime's shipped in-pane
// trust-grant prompt.
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 });
} catch { /* best-effort */ }
// Carry sessionName + evidence through to InstantiateResult.nodes
// so BootstrapOrchestrator can build AttentionNode[] in the mixed
// launched+attention_required path (guard verdict
// qitem-20260518082933 BLOCKER 1).
const resultNodes = nodeResults.map((n) => ({
logicalId: n.logicalId,
status: n.status,
error: n.error,
sessionName: n.sessionName,
evidence: n.evidence,
}));
return {
ok: true,
result: { rigId, specName: rigSpec.name, specVersion: rigSpec.version, nodes: nodeResults, warnings: podInstantiateWarnings.length > 0 ? podInstantiateWarnings : undefined },
result: { rigId, specName: rigSpec.name, specVersion: rigSpec.version, nodes: resultNodes, warnings: podInstantiateWarnings.length > 0 ? podInstantiateWarnings : undefined },
};
}
@@ -986,7 +1045,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 +1274,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,
};
+45 -3
View File
@@ -937,13 +937,51 @@ 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):
// 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.
//
// `sessionName` + `evidence` carry runtime detail needed by
// BootstrapOrchestrator to construct AttentionNode[] for the mixed
// launched+attention_required path (the route's 3-part error
// response needs sessionName for the tmux-attach hint). Optional
// because legacy callers don't supply them on terminal-only paths.
nodes: {
logicalId: string;
status: "launched" | "failed" | "attention_required";
error?: string;
sessionName?: string;
evidence?: string;
}[];
warnings?: string[];
}
@@ -985,7 +1023,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;
}
+62
View File
@@ -12,6 +12,56 @@ import { assessCurrentStateRehydrateEligibility } from "../domain/rehydrate-elig
export const upRoutes = new Hono();
/**
* OPR.0.3.2.CT — assemble the 3-part error response body
* (fact / consequence / action) when a bootstrap result carries an
* import_rig stage with status='blocked' and detail.code ===
* 'attention_required'. Returns null when the result is not in that
* shape (caller falls through to the normal partial-success path).
*
* Exported for narrow unit tests so the HG-4 shape is pinned without
* needing the full daemon harness. Pure on the result shape.
*/
export function buildAttentionResponse(result: {
rigId?: string;
stages: Array<{ stage: string; status: string; detail?: unknown }>;
}): {
error: { fact: string; consequence: string; action: string };
attentionNodes: import("../domain/types.js").AttentionNode[];
} | null {
const attentionStage = result.stages.find(
(s) => s.stage === "import_rig"
&& s.status === "blocked"
&& (s.detail as { code?: string } | undefined)?.code === "attention_required",
);
if (!attentionStage) return null;
const detail = attentionStage.detail as {
code: string;
message: string;
attentionNodes: import("../domain/types.js").AttentionNode[];
};
const nodeCount = detail.attentionNodes.length;
const sessionAttachHints = detail.attentionNodes
.filter((n) => n.sessionName)
.map((n) => `tmux attach -t ${n.sessionName}`)
const visibleAttachHints = sessionAttachHints.slice(0, 3).join(" ; ");
const remainingAttachHintCount = Math.max(0, sessionAttachHints.length - 3);
const attachHintText = visibleAttachHints
? `${visibleAttachHints}${remainingAttachHintCount > 0 ? ` ; plus ${remainingAttachHintCount} more listed in attentionNodes` : ""}`
: "see `rig ps`";
const rigIdDisplay = result.rigId ?? "(rigId unavailable)";
return {
error: {
fact: detail.message,
consequence: `Rig ${rigIdDisplay} 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: ${attachHintText}.`
: `Attach to each parked session listed in attentionNodes and answer its prompt: ${attachHintText}.`,
},
attentionNodes: detail.attentionNodes,
};
}
function getDeps(c: { get: (key: string) => unknown }) {
return {
bootstrapOrchestrator: c.get("bootstrapOrchestrator" as never) as BootstrapOrchestrator,
@@ -228,6 +278,18 @@ upRoutes.post("/", async (c) => {
attachCommand = firstRunning?.tmuxAttachCommand ?? inventory.find((n) => n.canonicalSessionName)?.tmuxAttachCommand ?? null;
}
// OPR.0.3.2.CT — when the partial outcome carries an
// attention_required import_rig stage (either the all-attention
// path from the instantiator's new outcome variant OR the
// mixed launched+attention path the orchestrator routes the
// same way), 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.
const attentionResponse = buildAttentionResponse(result);
if (attentionResponse) {
return c.json({ ...result, attachCommand, ...attentionResponse }, 409);
}
return c.json({ ...result, attachCommand }, 200);
}
eventBus.emit({ type: "bootstrap.failed", runId: result.runId, sourceRef, error: result.errors[0] ?? "failed" });
+4 -1
View File
@@ -1218,7 +1218,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
deps.sessionTransport,
)
: undefined;
const contextMonitor = new ContextMonitor(db, contextUsageStore, claudeAdapter, compactionEnforcer);
const contextMonitor = new ContextMonitor(db, contextUsageStore, claudeAdapter, compactionEnforcer, {
"claude-code": claudeAdapter,
codex: codexAdapter,
});
deps.contextMonitor = contextMonitor;
const app = createApp(deps);
@@ -1257,4 +1257,210 @@ edges: []
expect(result.rigId).toBe("rig-pod-1");
expect(mockPodInstantiator.instantiate).toHaveBeenCalledTimes(1);
});
// --- Conveyor-Trust Minimal Fix (OPR.0.3.2.CT) — guard verdict
// qitem-20260518082933 BLOCKER 1: mixed launched+attention_required
// must NOT be surfaced as "completed"; orchestrator must route
// attention_required nodes through a partial+blocked import_rig
// stage carrying attentionNodes so the route can build the
// 3-part error.
it("OPR.0.3.2.CT BLOCKER-1: mixed launched + attention_required → status=partial, import_rig stage=blocked with attentionNodes", async () => {
const podSpecYaml = `
version: "0.2"
name: pod-mixed-rig
pods:
- id: dev
label: Dev
members:
- id: impl
agent_ref: "local:agents/impl"
profile: default
runtime: claude-code
cwd: .
- id: qa
agent_ref: "local:agents/qa"
profile: default
runtime: claude-code
cwd: .
edges: []
edges: []
`.trim();
const specPath = path.join(tmpDir, "pod-mixed-spec.yaml");
fs.writeFileSync(specPath, podSpecYaml);
const mockPodInstantiator = {
db,
instantiate: vi.fn(async () => ({
ok: true as const,
result: {
rigId: "rig-mixed-1",
specName: "pod-mixed-rig",
specVersion: "0.2",
nodes: [
{ logicalId: "dev.impl", status: "launched" as const, sessionName: "dev-impl@pod-mixed-rig" },
{ logicalId: "dev.qa", status: "attention_required" as const, error: "trust_gate on qa", sessionName: "dev-qa@pod-mixed-rig", evidence: "trust prompt visible" },
],
},
})),
};
const orch = new BootstrapOrchestrator({
db,
bootstrapRepo: new BootstrapRepository(db),
runtimeVerifier: new RuntimeVerifier({ exec: createMockExec({}), db }),
probeRegistry: new RequirementsProbeRegistry(createMockExec({})),
installPlanner: new ExternalInstallPlanner(),
installExecutor: new ExternalInstallExecutor({ exec: createMockExec({}), db }),
packageInstallService: new PackageInstallService({
packageRepo: new PackageRepository(db),
installRepo: new InstallRepository(db),
installEngine: new InstallEngine(new InstallRepository(db), realEngineFsOps()),
installVerifier: new InstallVerifier(new InstallRepository(db), new PackageRepository(db), {
readFile: (p) => fs.readFileSync(p, "utf-8"), exists: (p) => fs.existsSync(p),
}),
}),
rigInstantiator: createMockInstantiator(db) as any,
fsOps: realFsOps(),
bundleSourceResolver: null,
podInstantiator: mockPodInstantiator as any,
});
const result = await orch.bootstrap({ mode: "apply", sourceRef: specPath, sourceKind: "rig_spec" });
// BLOCKER-1: NOT "completed" when any node attention_required.
expect(result.status).toBe("partial");
expect(result.rigId).toBe("rig-mixed-1");
const importStage = result.stages.find((s) => s.stage === "import_rig");
expect(importStage).toBeDefined();
expect(importStage!.status).toBe("blocked");
const detail = importStage!.detail as { code: string; attentionNodes: Array<{ logicalId: string; sessionName: string; evidence?: string }> };
expect(detail.code).toBe("attention_required");
expect(detail.attentionNodes.length).toBe(1);
expect(detail.attentionNodes[0]!.logicalId).toBe("dev.qa");
expect(detail.attentionNodes[0]!.sessionName).toBe("dev-qa@pod-mixed-rig");
expect(detail.attentionNodes[0]!.evidence).toBe("trust prompt visible");
});
it("OPR.0.3.2.CT BLOCKER-1: all-launched (no attention, no failed) → status=completed, import_rig=ok (no regression)", async () => {
const podSpecYaml = `
version: "0.2"
name: pod-clean-rig
pods:
- id: dev
label: Dev
members:
- id: impl
agent_ref: "local:agents/impl"
profile: default
runtime: claude-code
cwd: .
edges: []
edges: []
`.trim();
const specPath = path.join(tmpDir, "pod-clean-spec.yaml");
fs.writeFileSync(specPath, podSpecYaml);
const mockPodInstantiator = {
db,
instantiate: vi.fn(async () => ({
ok: true as const,
result: {
rigId: "rig-clean-1",
specName: "pod-clean-rig",
specVersion: "0.2",
nodes: [{ logicalId: "dev.impl", status: "launched" as const, sessionName: "dev-impl@pod-clean-rig" }],
},
})),
};
const orch = new BootstrapOrchestrator({
db,
bootstrapRepo: new BootstrapRepository(db),
runtimeVerifier: new RuntimeVerifier({ exec: createMockExec({}), db }),
probeRegistry: new RequirementsProbeRegistry(createMockExec({})),
installPlanner: new ExternalInstallPlanner(),
installExecutor: new ExternalInstallExecutor({ exec: createMockExec({}), db }),
packageInstallService: new PackageInstallService({
packageRepo: new PackageRepository(db),
installRepo: new InstallRepository(db),
installEngine: new InstallEngine(new InstallRepository(db), realEngineFsOps()),
installVerifier: new InstallVerifier(new InstallRepository(db), new PackageRepository(db), {
readFile: (p) => fs.readFileSync(p, "utf-8"), exists: (p) => fs.existsSync(p),
}),
}),
rigInstantiator: createMockInstantiator(db) as any,
fsOps: realFsOps(),
bundleSourceResolver: null,
podInstantiator: mockPodInstantiator as any,
});
const result = await orch.bootstrap({ mode: "apply", sourceRef: specPath, sourceKind: "rig_spec" });
expect(result.status).toBe("completed");
const importStage = result.stages.find((s) => s.stage === "import_rig");
expect(importStage!.status).toBe("ok");
});
it("OPR.0.3.2.CT BLOCKER-1: all-attention_required outcome → status=partial, import_rig=blocked, rigId preserved", async () => {
const podSpecYaml = `
version: "0.2"
name: pod-all-attention-rig
pods:
- id: dev
label: Dev
members:
- id: impl
agent_ref: "local:agents/impl"
profile: default
runtime: claude-code
cwd: .
edges: []
edges: []
`.trim();
const specPath = path.join(tmpDir, "pod-all-attention-spec.yaml");
fs.writeFileSync(specPath, podSpecYaml);
// PodRigInstantiator returns the new attention_required outcome
// (ok:false with rigId + attentionNodes) when ALL nodes are parked.
const mockPodInstantiator = {
db,
instantiate: vi.fn(async () => ({
ok: false as const,
code: "attention_required" as const,
message: "1 node requires attention before becoming interactive (rig parked, NOT failed; approve and resume to proceed).",
rigId: "rig-all-attention-1",
attentionNodes: [{ logicalId: "dev.impl", sessionName: "dev-impl@pod-all-attention-rig", evidence: "trust prompt", reason: "trust_gate" }],
})),
};
const orch = new BootstrapOrchestrator({
db,
bootstrapRepo: new BootstrapRepository(db),
runtimeVerifier: new RuntimeVerifier({ exec: createMockExec({}), db }),
probeRegistry: new RequirementsProbeRegistry(createMockExec({})),
installPlanner: new ExternalInstallPlanner(),
installExecutor: new ExternalInstallExecutor({ exec: createMockExec({}), db }),
packageInstallService: new PackageInstallService({
packageRepo: new PackageRepository(db),
installRepo: new InstallRepository(db),
installEngine: new InstallEngine(new InstallRepository(db), realEngineFsOps()),
installVerifier: new InstallVerifier(new InstallRepository(db), new PackageRepository(db), {
readFile: (p) => fs.readFileSync(p, "utf-8"), exists: (p) => fs.existsSync(p),
}),
}),
rigInstantiator: createMockInstantiator(db) as any,
fsOps: realFsOps(),
bundleSourceResolver: null,
podInstantiator: mockPodInstantiator as any,
});
const result = await orch.bootstrap({ mode: "apply", sourceRef: specPath, sourceKind: "rig_spec" });
expect(result.status).toBe("partial");
expect(result.rigId).toBe("rig-all-attention-1");
const importStage = result.stages.find((s) => s.stage === "import_rig");
expect(importStage!.status).toBe("blocked");
const detail = importStage!.detail as { code: string; attentionNodes: Array<{ logicalId: string }> };
expect(detail.code).toBe("attention_required");
expect(detail.attentionNodes.length).toBe(1);
});
});
@@ -355,6 +355,37 @@ describe("ContextMonitor", () => {
expect(refreshed.startup_status).toBe("failed");
});
it("pollOnce normalizes stale Codex attention_required state after the trust prompt is cleared", async () => {
const rig = rigRepo.createRig("test-rig-codex-trust");
const node = rigRepo.addNode(rig.id, "dev.qa", { runtime: "codex", cwd: "/project" });
const session = sessionRegistry.registerSession(node.id, "dev-qa-trust@test");
db.prepare("UPDATE sessions SET status = 'running', startup_status = 'attention_required', resume_token = NULL WHERE id = ?")
.run(session.id);
const codexReadySpy = vi.fn(async (): Promise<ReadinessResult> => ({ ready: true }));
monitor = new ContextMonitor(db, store, {
ensureContextCollector: ensureContextCollectorSpy,
checkReady: checkReadySpy,
}, undefined, {
"claude-code": { checkReady: checkReadySpy },
codex: { checkReady: codexReadySpy },
});
await monitor.pollOnce();
const refreshed = db.prepare("SELECT startup_status, startup_completed_at FROM sessions WHERE id = ?").get(session.id) as {
startup_status: string;
startup_completed_at: string | null;
};
expect(refreshed.startup_status).toBe("ready");
expect(refreshed.startup_completed_at).toBeTruthy();
expect(codexReadySpy).toHaveBeenCalledWith(expect.objectContaining({
nodeId: session.nodeId,
tmuxSession: "dev-qa-trust@test",
cwd: "/project",
}));
});
it("pollOnce does not overwrite pending Claude startup state", async () => {
const { sessionName, session } = (() => {
const rig = rigRepo.createRig("test-rig-6");
@@ -221,6 +221,35 @@ describe("native resume probe", () => {
});
});
it("classifies Codex as active when the current TUI appears below an old trust prompt in scrollback", () => {
expect(
assessNativeResumeProbe({
runtime: "codex",
paneCommand: "codex-aarch64-a",
paneContent: [
"> You are in /some/workspace",
"",
" Do you trust the contents of this directory? Working with untrusted contents",
"",
"› 1. Yes, continue",
" 2. No, quit",
"",
"╭────────────────────────────────────────────────────╮",
"│ >_ OpenAI Codex (v0.130.0) │",
"╰────────────────────────────────────────────────────╯",
"",
"› Write tests for @filename",
"",
" gpt-5.5 xhigh fast · Context 0% used · Fast on",
].join("\n"),
})
).toEqual({
status: "resumed",
code: "active_runtime",
detail: "Codex is running with an active interactive TUI in the probe pane.",
});
});
it("classifies Codex update prompts as inconclusive", () => {
expect(
assessNativeResumeProbe({
@@ -722,4 +722,284 @@ state: 2-named
fs.rmSync(tmpRegistry, { recursive: true, force: true });
}
});
// --- Conveyor-Trust Minimal Fix (OPR.0.3.2.CT) ---
//
// 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();
});
});
+296
View File
@@ -7,6 +7,7 @@ import type { RigRepository } from "../src/domain/rig-repository.js";
import type { SessionRegistry } from "../src/domain/session-registry.js";
import type { SnapshotCapture } from "../src/domain/snapshot-capture.js";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
import { buildAttentionResponse } from "../src/routes/up.js";
const VALID_SPEC = `
schema_version: 1
@@ -498,4 +499,299 @@ edges: []
expect(body.error).toContain("not a valid rig spec");
});
});
// --- Conveyor-Trust Minimal Fix (OPR.0.3.2.CT) — guard verdict
// qitem-20260518082933 BLOCKER 2: HG-4 route response shape must
// be pinned by tests. buildAttentionResponse is the pure
// helper that constructs the 3-part error from a bootstrap
// result with a blocked/attention_required import_rig stage; the
// route returns 409 with that body. These tests pin the shape
// without needing the full daemon harness.
describe("OPR.0.3.2.CT BLOCKER-2: HG-4 3-part error shape", () => {
it("returns null when no import_rig stage is blocked/attention_required (normal partial path)", () => {
const result = {
rigId: "rig-1",
stages: [
{ stage: "resolve_spec", status: "ok" },
{ stage: "import_rig", status: "ok", detail: { rigId: "rig-1", nodes: [] } },
],
};
expect(buildAttentionResponse(result)).toBeNull();
});
it("returns null on a 'failed' import_rig stage (no attention_required code)", () => {
const result = {
rigId: "rig-1",
stages: [
{ stage: "import_rig", status: "failed", detail: { code: "instantiate_error" } },
],
};
expect(buildAttentionResponse(result)).toBeNull();
});
it("HG-4: blocked + attention_required stage → fact/consequence/action with attentionNodes", () => {
const result = {
rigId: "rig-conveyor-1",
stages: [
{
stage: "import_rig",
status: "blocked",
detail: {
code: "attention_required",
message: "1 node requires attention before becoming interactive (rig parked, NOT failed; approve and resume to proceed).",
attentionNodes: [
{ logicalId: "dev.impl", sessionName: "dev-impl@conveyor", evidence: "trust prompt", reason: "trust_gate" },
],
},
},
],
};
const r = buildAttentionResponse(result);
expect(r).not.toBeNull();
expect(r!.error.fact).toMatch(/requires attention/i);
expect(r!.error.consequence).toMatch(/rig-conveyor-1/);
expect(r!.error.consequence).toMatch(/rig ps/);
expect(r!.error.consequence).toMatch(/attention_required/);
expect(r!.error.action).toMatch(/tmux attach -t dev-impl@conveyor/);
expect(r!.error.action).not.toMatch(/rig setup --cwd/);
// Singular phrasing for a 1-node case
expect(r!.error.action).toMatch(/^Attach to the session/);
expect(r!.attentionNodes).toHaveLength(1);
expect(r!.attentionNodes[0]!.logicalId).toBe("dev.impl");
});
it("HG-4: multi-node action message uses plural phrasing + lists multiple tmux attach hints", () => {
const result = {
rigId: "rig-conveyor-2",
stages: [
{
stage: "import_rig",
status: "blocked",
detail: {
code: "attention_required",
message: "3 nodes require attention before becoming interactive.",
attentionNodes: [
{ logicalId: "dev.impl", sessionName: "dev-impl@conveyor", reason: "trust_gate" },
{ logicalId: "dev.qa", sessionName: "dev-qa@conveyor", reason: "trust_gate" },
{ logicalId: "dev.review", sessionName: "dev-review@conveyor", reason: "trust_gate" },
],
},
},
],
};
const r = buildAttentionResponse(result);
expect(r).not.toBeNull();
// Plural phrasing
expect(r!.error.action).toMatch(/^Attach to each parked session/);
// First 3 hints listed
expect(r!.error.action).toContain("tmux attach -t dev-impl@conveyor");
expect(r!.error.action).toContain("tmux attach -t dev-qa@conveyor");
expect(r!.error.action).toContain("tmux attach -t dev-review@conveyor");
expect(r!.attentionNodes).toHaveLength(3);
});
it("HG-4: multi-node action message points to attentionNodes when attach hints are abbreviated", () => {
const result = {
rigId: "rig-conveyor-4",
stages: [
{
stage: "import_rig",
status: "blocked",
detail: {
code: "attention_required",
message: "4 nodes require attention before becoming interactive.",
attentionNodes: [
{ logicalId: "intake.lead", sessionName: "intake-lead@conveyor", reason: "trust_gate" },
{ logicalId: "plan.planner", sessionName: "plan-planner@conveyor", reason: "trust_gate" },
{ logicalId: "build.builder", sessionName: "build-builder@conveyor", reason: "trust_gate" },
{ logicalId: "review.reviewer", sessionName: "review-reviewer@conveyor", reason: "trust_gate" },
],
},
},
],
};
const r = buildAttentionResponse(result);
expect(r).not.toBeNull();
expect(r!.error.action).toMatch(/^Attach to each parked session listed in attentionNodes/);
expect(r!.error.action).toContain("tmux attach -t intake-lead@conveyor");
expect(r!.error.action).toContain("tmux attach -t plan-planner@conveyor");
expect(r!.error.action).toContain("tmux attach -t build-builder@conveyor");
expect(r!.error.action).toContain("plus 1 more listed in attentionNodes");
expect(r!.error.action).not.toContain("review-reviewer@conveyor");
expect(r!.attentionNodes).toHaveLength(4);
});
it("HG-4: when no sessionName is present on any node, action falls back to 'see rig ps' (defense)", () => {
const result = {
rigId: "rig-conveyor-3",
stages: [
{
stage: "import_rig",
status: "blocked",
detail: {
code: "attention_required",
message: "1 node requires attention.",
attentionNodes: [
{ logicalId: "dev.impl", sessionName: "", reason: "trust_gate" },
],
},
},
],
};
const r = buildAttentionResponse(result);
expect(r!.error.action).toContain("see `rig ps`");
});
it("HG-4: result without rigId still produces a valid response (defense)", () => {
const result = {
stages: [
{
stage: "import_rig",
status: "blocked",
detail: {
code: "attention_required",
message: "1 node requires attention.",
attentionNodes: [
{ logicalId: "dev.impl", sessionName: "dev-impl@x", reason: "trust_gate" },
],
},
},
],
};
const r = buildAttentionResponse(result);
expect(r).not.toBeNull();
expect(r!.error.consequence).toContain("(rigId unavailable)");
});
});
// OPR.0.3.2.CT — route-level POST /api/up discriminator
//
// Guard re-verify (qitem-20260518083805) BLOCKER: the prior
// forward-fix added pure helper tests on buildAttentionResponse,
// but those would still pass even if the route stopped calling
// the helper or dropped 409. This test exercises the real route
// by stubbing bootstrapOrchestrator.bootstrap with a
// partial+blocked+attention result; it fails red if up.ts returns
// 200 (normal partial path) or 201 (completed path) instead of
// 409 with a 3-part body.
describe("OPR.0.3.2.CT BLOCKER-2 (route-level): POST /api/up surfaces 409 with 3-part body on attention_required partial", () => {
it("attention_required partial → 409 with error.fact / error.consequence / error.action / attentionNodes", async () => {
// Build a fresh app instance so we can monkey-patch the
// orchestrator without leaking state into other tests.
db.close();
const freshDb = createFullTestDb();
// Use real-fs upRouter so the spec file we write is resolvable.
const setup = createTestApp(freshDb, {
upRouterFsOps: {
exists: (p: string) => fs.existsSync(p),
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
readHead: (p: string, n: number) => {
const fd = fs.openSync(p, "r");
try {
const buf = Buffer.alloc(n);
const bytes = fs.readSync(fd, buf, 0, n, 0);
return buf.subarray(0, bytes);
} finally {
fs.closeSync(fd);
}
},
},
});
const freshApp = setup.app;
const bootstrapOrch = setup.bootstrapOrchestrator;
// Write a valid pod spec file so the route's pre-bootstrap
// resolution path succeeds and reaches the bootstrap() call.
const podSpecYaml = `
version: "0.2"
name: conveyor-attention-route-test
pods:
- id: dev
label: Dev
members:
- id: impl
agent_ref: "local:agents/impl"
profile: default
runtime: claude-code
cwd: .
edges: []
edges: []
`.trim();
const specPath = path.join(tmpDir, "attention-route-spec.yaml");
fs.writeFileSync(specPath, podSpecYaml);
// Stub the orchestrator's bootstrap to emit the
// partial+blocked+attention shape we expect from a real
// trust-gated launch. The route MUST translate this to 409.
const stubResult = {
runId: "run-stub-1",
status: "partial" as const,
rigId: "rig-stub-1",
stages: [
{ stage: "resolve_spec" as const, status: "ok" as const, detail: {} },
{
stage: "import_rig" as const,
status: "blocked" as const,
detail: {
code: "attention_required",
message: "1 node requires attention before becoming interactive (rig parked, NOT failed; approve and resume to proceed).",
rigId: "rig-stub-1",
specName: "conveyor-attention-route-test",
nodes: [{ logicalId: "dev.impl", status: "attention_required" as const, sessionName: "dev-impl@conveyor-attention-route-test", evidence: "trust prompt" }],
attentionNodes: [
{ logicalId: "dev.impl", sessionName: "dev-impl@conveyor-attention-route-test", evidence: "trust prompt", reason: "trust_gate" },
],
},
},
],
errors: ["1 node requires attention before becoming interactive (rig parked, NOT failed; approve and resume to proceed)."],
warnings: [],
};
const origBootstrap = bootstrapOrch.bootstrap.bind(bootstrapOrch);
bootstrapOrch.bootstrap = (async () => stubResult) as typeof bootstrapOrch.bootstrap;
// Also stub release() so we don't try to release an unknown
// sourceRef key in the orchestrator's internal map.
const origRelease = bootstrapOrch.release.bind(bootstrapOrch);
bootstrapOrch.release = (() => undefined) as typeof bootstrapOrch.release;
try {
const res = await freshApp.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: specPath }),
});
// BLOCKER-2 from re-verify: route MUST return 409 (not 200
// partial-success, not 201 completed).
expect(res.status).toBe(409);
const body = await res.json();
// HG-4 3-part error shape.
expect(body.error).toBeDefined();
expect(body.error.fact).toMatch(/require[s]? attention/i);
expect(body.error.consequence).toMatch(/rig-stub-1/);
expect(body.error.consequence).toMatch(/rig ps/);
expect(body.error.action).toMatch(/tmux attach -t dev-impl@conveyor-attention-route-test/);
expect(body.error.action).not.toMatch(/rig setup --cwd/);
// attentionNodes array carried through to operator
expect(body.attentionNodes).toBeInstanceOf(Array);
expect(body.attentionNodes).toHaveLength(1);
expect(body.attentionNodes[0].logicalId).toBe("dev.impl");
// The bootstrap's partial result still carries the rigId
// for `rig ps` lookups
expect(body.rigId).toBe("rig-stub-1");
// status remains "partial" so downstream tooling can branch
expect(body.status).toBe("partial");
} finally {
bootstrapOrch.bootstrap = origBootstrap;
bootstrapOrch.release = origRelease;
freshDb.close();
}
// Restore db so afterEach's close() doesn't double-close —
// but afterEach closes the original `db` which we already
// closed above. Replace it with a no-op stub for the rest of
// teardown.
db = { close: () => undefined } as unknown as Database.Database;
});
});
});