mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
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-cleared5264b19a, 0-after, base7f4085e6unmoved, 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:
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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user