mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
fix: improve Codex startup and restore reporting
This commit is contained in:
1 parent
9c9e518d29
commit
6553aaef3a
22 files changed
+873
-163
No files matched your search
@@ -8,6 +8,7 @@ import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps, daemonS
|
||||
import type { RiggedConfig } from "../config-store.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { formatThreePart, type ThreePartRejection } from "./workflow-errors.js";
|
||||
|
||||
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
|
||||
|
||||
@@ -295,7 +296,7 @@ Examples:
|
||||
console.error(`'${source}' is ambiguous — it matches both an existing rig restore target and a library spec.`);
|
||||
console.error(` To launch the library spec: rig up ${entry.sourcePath}`);
|
||||
console.error(` The rig-name match refers to a stopped rig / snapshot-backed restore path.`);
|
||||
console.error(` To power on the existing rig: rename or remove the library spec first, then retry.`);
|
||||
console.error(` To recover the existing rig instead of importing a starter: rig up ${source} --existing`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -442,7 +443,15 @@ Examples:
|
||||
|
||||
if (res.status >= 400) {
|
||||
const code = res.data["code"] as string | undefined;
|
||||
if (code === "cycle_error") {
|
||||
const error = res.data["error"] as Partial<ThreePartRejection> | null | undefined;
|
||||
if (error && typeof error === "object" && typeof error.fact === "string"
|
||||
&& typeof error.consequence === "string" && typeof error.action === "string") {
|
||||
for (const line of formatThreePart(error as ThreePartRejection)) console.error(line);
|
||||
const nodes = res.data["attentionNodes"] as Array<{ logicalId: string; sessionName?: string; reason: string }> | undefined;
|
||||
for (const node of nodes ?? []) {
|
||||
console.error(` ${node.logicalId}${node.sessionName ? ` (${node.sessionName})` : ""}: ${node.reason}`);
|
||||
}
|
||||
} else if (code === "cycle_error") {
|
||||
console.error("Cycle detected in rig topology. Check edge definitions for circular dependencies.");
|
||||
} else if (code === "validation_failed") {
|
||||
const errors = (res.data["errors"] as string[]) ?? [];
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { upCommand } from "../src/commands/up.js";
|
||||
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
import type { DaemonClient } from "../src/client.js";
|
||||
import { buildAttentionResponse } from "../../daemon/src/routes/up.js";
|
||||
|
||||
const attentionNodes = [
|
||||
{ logicalId: "dev.owner", sessionName: "dev-owner@first", reason: "update_gate", evidence: "Update available!" },
|
||||
{ logicalId: "dev.checker", sessionName: "dev-checker@first", reason: "returned_to_shell", evidence: "$" },
|
||||
];
|
||||
function attentionBody() {
|
||||
const result = { rigId: "first", stages: [{ stage: "import_rig", status: "blocked",
|
||||
detail: { code: "attention_required", message: "2 members need attention.", attentionNodes } }] };
|
||||
return { ...result, ...buildAttentionResponse(result)! };
|
||||
}
|
||||
|
||||
// Execute the actual command and daemon response builder; transport/lifecycle
|
||||
// are injected. No listener, daemon, tmux or provider process is started.
|
||||
async function render(data: Record<string, unknown>, status = 409, json = false) {
|
||||
const oldExit = process.exitCode;
|
||||
const out: string[] = [], err: string[] = [];
|
||||
const log = vi.spyOn(console, "log").mockImplementation((...s) => { out.push(s.join(" ")); });
|
||||
const error = vi.spyOn(console, "error").mockImplementation((...s) => { err.push(s.join(" ")); });
|
||||
const forbidden = () => { throw new Error("unexpected lifecycle operation"); };
|
||||
const lifecycleDeps = {
|
||||
spawn: forbidden, kill: forbidden, writeFile: forbidden, removeFile: forbidden,
|
||||
mkdirp: forbidden, openForAppend: forbidden,
|
||||
fetch: async () => ({ ok: true }), isProcessAlive: () => true,
|
||||
exists: (p: string) => p === STATE_FILE,
|
||||
readFile: (p: string) => p === STATE_FILE ? JSON.stringify({ pid: 123, port: 12345, db: "mock", startedAt: "2000-01-01" }) : null,
|
||||
} as LifecycleDeps;
|
||||
const client = { get: vi.fn(async () => ({ status: 200, data: [] })),
|
||||
post: vi.fn(async () => ({ status, data })) };
|
||||
process.exitCode = undefined;
|
||||
try {
|
||||
const cmd = new Command().exitOverride().addCommand(upCommand({
|
||||
lifecycleDeps, clientFactory: () => client as unknown as DaemonClient,
|
||||
preflightExec: forbidden,
|
||||
}));
|
||||
await cmd.parseAsync(["node", "rig", "up", "first-project", ...(json ? ["--json"] : [])]);
|
||||
expect(client.post).toHaveBeenCalledTimes(1);
|
||||
return { out: out.join("\n"), err: err.join("\n"), exit: process.exitCode };
|
||||
} finally { process.exitCode = oldExit; log.mockRestore(); error.mockRestore(); }
|
||||
}
|
||||
|
||||
describe("startup attention guidance", () => {
|
||||
it("renders actual structured response and all affected members without spec advice", async () => {
|
||||
const body = attentionBody();
|
||||
const r = await render(body);
|
||||
expect(r.err).toContain(`Error: ${body.error.fact}`);
|
||||
expect(r.err).toContain(body.error.consequence);
|
||||
expect(r.err).toContain(body.error.action);
|
||||
for (const n of attentionNodes) {
|
||||
expect(r.err).toContain(n.logicalId);
|
||||
expect(r.err).toContain(n.sessionName);
|
||||
expect(r.err).toContain(n.reason);
|
||||
}
|
||||
expect(r.err).not.toMatch(/\[object Object\]|validate your spec|Sessions are running|answering it.*completes/);
|
||||
expect(r.out).toContain("import_rig: blocked");
|
||||
expect(r.exit).toBe(1);
|
||||
});
|
||||
it("daemon guidance does not assume a trust prompt or a live runtime", () => {
|
||||
const { error } = attentionBody();
|
||||
expect(error.consequence).toContain("attention_required");
|
||||
expect(error.consequence).toMatch(/not.*proven|not.*confirmed/i);
|
||||
expect(error.action).toMatch(/inspect/i);
|
||||
expect(error.action).not.toMatch(/answer.*prompt|parked session/i);
|
||||
});
|
||||
it.each([409, 500])("preserves exact JSON body and exit for HTTP %i", async (status) => {
|
||||
const data = attentionBody();
|
||||
const r = await render(data, status, true);
|
||||
expect(r.out).toBe(JSON.stringify(data));
|
||||
expect(r.err).toBe("");
|
||||
expect(r.exit).toBe(status === 409 ? 1 : 2);
|
||||
});
|
||||
it.each([409, 500])("preserves string fallback and exit for HTTP %i", async (status) => {
|
||||
const r = await render({ error: "agent_ref resolution failed", stages: [] }, status);
|
||||
expect(r.err).toContain(`Up failed: agent_ref resolution failed (HTTP ${status})`);
|
||||
expect(r.err).toContain("local: agent_ref paths resolve relative");
|
||||
expect(r.exit).toBe(status === 409 ? 1 : 2);
|
||||
});
|
||||
});
|
||||
@@ -877,10 +877,12 @@ describe("Up CLI", () => {
|
||||
|
||||
it("up with library name matching existing rig shows ambiguity error", async () => {
|
||||
// Mock server that has both a library spec and an existing rig named "my-rig"
|
||||
let startupRequests = 0;
|
||||
const origListeners = server.listeners("request");
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
const url = decodeURIComponent(req.url ?? "");
|
||||
if (url === "/api/up") startupRequests++;
|
||||
if (url.startsWith("/api/specs/library")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([{ id: "lib1", name: "alpha", sourcePath: "/specs/alpha.yaml" }]));
|
||||
@@ -903,6 +905,9 @@ describe("Up CLI", () => {
|
||||
expect(logs.join("\n")).toContain("ambiguous");
|
||||
expect(logs.join("\n")).toContain("existing rig restore target");
|
||||
expect(logs.join("\n")).toContain("/specs/alpha.yaml");
|
||||
expect(logs.join("\n")).toContain("rig up alpha --existing");
|
||||
expect(logs.join("\n")).not.toContain("rename or remove");
|
||||
expect(startupRequests).toBe(0);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
@@ -942,7 +947,7 @@ describe("Up CLI", () => {
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("up --existing bypasses library-name ambiguity and posts the rig name", async () => {
|
||||
it.each([false, true])("up --existing bypasses library-name ambiguity with plan=%s", async (plan) => {
|
||||
const origListeners = server.listeners("request");
|
||||
let lastBody: Record<string, unknown> = {};
|
||||
server.removeAllListeners("request");
|
||||
@@ -963,13 +968,14 @@ describe("Up CLI", () => {
|
||||
});
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "alpha", "--existing"]);
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "alpha", "--existing", ...(plan ? ["--plan"] : [])]);
|
||||
});
|
||||
|
||||
server.removeAllListeners("request");
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
|
||||
expect(lastBody.sourceRef).toBe("alpha");
|
||||
expect(lastBody.plan).toBe(plan);
|
||||
expect(logs.join("\n")).toContain('Recovering rig "alpha" from latest snapshot or current DB state');
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -2,8 +2,6 @@ import nodePath from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import Database from "better-sqlite3";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import type { TmuxAdapter } from "./tmux.js";
|
||||
@@ -28,9 +26,18 @@ import { assessNativeResumeProbe, buildCodexResumeCore, type NativeResumeProbeRe
|
||||
import { mergeManagedBlock } from "../domain/managed-blocks.js";
|
||||
import { parseSessionName } from "../domain/session-name.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { runSyncSite, runAsyncSite } from "../domain/sync-site-wrap.js";
|
||||
import { runSyncSite } from "../domain/sync-site-wrap.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
import { listNativeProcesses, observeCodexPaneProcess, type NativeProcessRow } from "../domain/native-process-lineage.js";
|
||||
|
||||
// Shared by all probes of ONE launch, never reset by a delayed screen or an
|
||||
// ambiguous transport result. A separately requested launch gets a new attempt.
|
||||
interface UpdatePromptAttempt {
|
||||
handled: boolean;
|
||||
failure?: Extract<HarnessLaunchResult, { ok: false }>;
|
||||
}
|
||||
|
||||
type CodexProcess = NativeProcessRow;
|
||||
|
||||
export interface CodexAdapterFsOps {
|
||||
readFile(path: string): string;
|
||||
@@ -53,7 +60,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
readonly runtime = "codex";
|
||||
private tmux: TmuxAdapter;
|
||||
private fs: CodexAdapterFsOps;
|
||||
private listProcesses: () => Array<{ pid: number; ppid: number; command: string }> | Promise<Array<{ pid: number; ppid: number; command: string }>>;
|
||||
private listProcesses: () => CodexProcess[] | Promise<CodexProcess[]>;
|
||||
private readThreadIdByPid: (pid: number) => Promise<string | undefined> | string | undefined;
|
||||
private sleep: (ms: number) => Promise<void>;
|
||||
private resolveHomeDirByPid: ResolveHomeDirByPid;
|
||||
@@ -75,7 +82,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
constructor(deps: {
|
||||
tmux: TmuxAdapter;
|
||||
fsOps: CodexAdapterFsOps;
|
||||
listProcesses?: () => Array<{ pid: number; ppid: number; command: string }> | Promise<Array<{ pid: number; ppid: number; command: string }>>;
|
||||
listProcesses?: () => CodexProcess[] | Promise<CodexProcess[]>;
|
||||
readThreadIdByPid?: (pid: number) => Promise<string | undefined> | string | undefined;
|
||||
resolveHomeDirByPid?: ResolveHomeDirByPid;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
@@ -313,6 +320,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
return { ok: false, error: "resumeToken and forkSource are mutually exclusive — pick one" };
|
||||
}
|
||||
|
||||
const updatePrompt: UpdatePromptAttempt = { handled: false };
|
||||
const model = binding.model?.trim();
|
||||
const modelArg = model ? ` -m ${shellQuote(model)}` : "";
|
||||
const profile = binding.codexConfigProfile?.trim();
|
||||
@@ -359,8 +367,10 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
|
||||
}
|
||||
await this.dismissCodexInteractiveGates(binding.tmuxSession);
|
||||
const threadId = await this.captureFreshThreadId(binding);
|
||||
await this.dismissSkippableCodexUpdatePrompt(binding.tmuxSession, updatePrompt, 8);
|
||||
if (updatePrompt.failure) return updatePrompt.failure;
|
||||
const threadId = await this.captureFreshThreadId(binding, updatePrompt);
|
||||
if (updatePrompt.failure) return updatePrompt.failure;
|
||||
if (!threadId) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -384,15 +394,17 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
|
||||
}
|
||||
|
||||
await this.dismissSkippableCodexUpdatePrompt(binding.tmuxSession);
|
||||
await this.dismissSkippableCodexUpdatePrompt(binding.tmuxSession, updatePrompt);
|
||||
if (updatePrompt.failure) return updatePrompt.failure;
|
||||
|
||||
if (opts.resumeToken) {
|
||||
const verification = await this.verifyResumeLaunch(binding.tmuxSession, { resumeToken: opts.resumeToken });
|
||||
const verification = await this.verifyResumeLaunch(binding.tmuxSession, updatePrompt, { resumeToken: opts.resumeToken });
|
||||
if (!verification.ok) return verification;
|
||||
return { ok: true, resumeToken: opts.resumeToken, resumeType: "codex_id", appliedLaunch };
|
||||
}
|
||||
|
||||
const threadId = await this.captureFreshThreadId(binding);
|
||||
const threadId = await this.captureFreshThreadId(binding, updatePrompt);
|
||||
if (updatePrompt.failure) return updatePrompt.failure;
|
||||
if (threadId) {
|
||||
return { ok: true, resumeToken: threadId, resumeType: "codex_id", appliedLaunch };
|
||||
}
|
||||
@@ -439,72 +451,66 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
return { ready: false, reason: probe.detail, code: probe.code };
|
||||
}
|
||||
|
||||
private async dismissSkippableCodexUpdatePrompt(tmuxSession: string, attempts = 6): Promise<boolean> {
|
||||
private async dismissSkippableCodexUpdatePrompt(
|
||||
tmuxSession: string, updatePrompt: UpdatePromptAttempt, attempts = 6,
|
||||
): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const paneCommand = await this.tmux.getPaneCommand(tmuxSession);
|
||||
const paneContent = await this.captureProbeScreen(tmuxSession);
|
||||
const probe = assessNativeResumeProbe({
|
||||
runtime: "codex",
|
||||
paneCommand,
|
||||
paneContent,
|
||||
});
|
||||
const probe = assessNativeResumeProbe({ runtime: "codex", paneCommand, paneContent });
|
||||
|
||||
if (probe.code === "update_gate") {
|
||||
if (!isSkippableCodexUpdatePrompt(paneContent)) return false;
|
||||
if (updatePrompt.handled || !isSkippableCodexUpdatePrompt(paneContent)) return false;
|
||||
const identity = await this.observeMenuProcess(tmuxSession, paneCommand);
|
||||
if (!identity) return false;
|
||||
// The npm launcher may be foreground Node with a native Codex child.
|
||||
// Recheck identity and CURRENT screen after sampling, never scrollback.
|
||||
const currentCommand = await this.tmux.getPaneCommand(tmuxSession);
|
||||
if (await this.observeMenuProcess(tmuxSession, currentCommand) !== identity) {
|
||||
updatePrompt.handled = true;
|
||||
return false;
|
||||
}
|
||||
const currentScreen = await this.tmux.capturePaneScreen?.(tmuxSession);
|
||||
if (!currentScreen || !isSkippableCodexUpdatePrompt(currentScreen)
|
||||
|| assessNativeResumeProbe({ runtime: "codex", paneCommand: currentCommand, paneContent: currentScreen }).code !== "update_gate") {
|
||||
updatePrompt.handled = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const textResult = await this.tmux.sendText(tmuxSession, "3");
|
||||
if (!textResult.ok) return false;
|
||||
const enterResult = await this.tmux.sendKeys(tmuxSession, ["Enter"]);
|
||||
if (!enterResult.ok) return false;
|
||||
// Codex 0.155.1 ignores Paste; Key3 selects AND submits DontRemind
|
||||
// (including its version-cache write). An Enter would hit the next
|
||||
// screen. Consume the attempt before sending, even if delivery fails.
|
||||
updatePrompt.handled = true;
|
||||
const result = await this.tmux.sendKeys(tmuxSession, ["3"]);
|
||||
if (!result.ok) {
|
||||
updatePrompt.failure = {
|
||||
ok: false, recovery: "attention_required",
|
||||
error: `Could not skip the Codex update prompt: ${result.message}. Inspect the session before retrying.`,
|
||||
evidence: paneContent.split("\n").slice(-12).join("\n"),
|
||||
};
|
||||
return false;
|
||||
}
|
||||
await this.sleep(500);
|
||||
return true;
|
||||
continue; // Observe transition; never send a second choice on this launch.
|
||||
}
|
||||
|
||||
if (probe.status === "resumed" || probe.code === "trust_gate") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (attempt < attempts - 1) {
|
||||
await this.sleep(200);
|
||||
// Readiness or another native decision closes update automation. A later
|
||||
// stale capture must not reopen it. Trust/auth decisions remain in-pane.
|
||||
if (probe.status === "resumed" || probe.status === "attention_required"
|
||||
|| probe.code === "trust_gate" || probe.code === "hook_trust_gate") {
|
||||
updatePrompt.handled = true;
|
||||
return probe.status === "resumed";
|
||||
}
|
||||
if (attempt < attempts - 1) await this.sleep(200);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async dismissCodexInteractiveGates(tmuxSession: string, attempts = 8): Promise<void> {
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const paneCommand = await this.tmux.getPaneCommand(tmuxSession);
|
||||
const paneContent = await this.captureProbeScreen(tmuxSession);
|
||||
const probe = assessNativeResumeProbe({
|
||||
runtime: "codex",
|
||||
paneCommand,
|
||||
paneContent,
|
||||
});
|
||||
|
||||
if (probe.code === "update_gate") {
|
||||
if (!isSkippableCodexUpdatePrompt(paneContent)) return;
|
||||
const textResult = await this.tmux.sendText(tmuxSession, "3");
|
||||
if (!textResult.ok) return;
|
||||
const enterResult = await this.tmux.sendKeys(tmuxSession, ["Enter"]);
|
||||
if (!enterResult.ok) return;
|
||||
await this.sleep(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Activity hooks are provisioned by exact authored hash above. A remaining
|
||||
// review can include unrelated hooks or a changed native UI; never type
|
||||
// a blanket trust choice into it. The TUI exposes the native decision.
|
||||
if (probe.code === "hook_trust_gate") return;
|
||||
|
||||
if (probe.status === "resumed" || probe.code === "trust_gate") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt < attempts - 1) {
|
||||
await this.sleep(200);
|
||||
}
|
||||
}
|
||||
private async observeMenuProcess(target: string, paneCommand: string | null): Promise<string | null> {
|
||||
// tmux may name the shell wrapper; native ancestry and foreground group decide identity.
|
||||
if (!paneCommand) return null;
|
||||
const observation = await observeCodexPaneProcess({ target, tmux: this.tmux, listProcesses: this.listProcesses });
|
||||
return observation ? JSON.stringify([paneCommand, observation.fingerprint]) : null;
|
||||
}
|
||||
|
||||
ensureManagedBootstrap(binding: { cwd?: string | null }): void {
|
||||
@@ -700,7 +706,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
return Array.from(keys);
|
||||
}
|
||||
|
||||
private async captureFreshThreadId(binding: NodeBinding): Promise<string | undefined> {
|
||||
private async captureFreshThreadId(binding: NodeBinding, updatePrompt: UpdatePromptAttempt): Promise<string | undefined> {
|
||||
const target = binding.tmuxPane ?? binding.tmuxSession;
|
||||
if (!target || !this.tmux.getPanePid) return undefined;
|
||||
|
||||
@@ -714,7 +720,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
}
|
||||
}
|
||||
if (binding.tmuxSession) {
|
||||
await this.dismissCodexInteractiveGates(binding.tmuxSession, 1);
|
||||
await this.dismissSkippableCodexUpdatePrompt(binding.tmuxSession, updatePrompt, 1);
|
||||
if (updatePrompt.failure) return undefined;
|
||||
}
|
||||
await this.sleep(250);
|
||||
}
|
||||
@@ -722,7 +729,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async verifyResumeLaunch(tmuxSession: string, opts?: { resumeToken?: string }): Promise<HarnessLaunchResult> {
|
||||
private async verifyResumeLaunch(tmuxSession: string, updatePrompt: UpdatePromptAttempt, opts?: { resumeToken?: string }): Promise<HarnessLaunchResult> {
|
||||
const quickAttempts = 6;
|
||||
const extendedAttempts = 24;
|
||||
const quickSleepMs = 200;
|
||||
@@ -794,7 +801,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
}
|
||||
|
||||
if (probe.code === "update_gate") {
|
||||
const dismissed = await this.dismissSkippableCodexUpdatePrompt(tmuxSession, 1);
|
||||
const dismissed = await this.dismissSkippableCodexUpdatePrompt(tmuxSession, updatePrompt, 1);
|
||||
if (updatePrompt.failure) return updatePrompt.failure;
|
||||
if (dismissed) {
|
||||
lastUnresolved = null;
|
||||
sawRealGate = false;
|
||||
@@ -1434,30 +1442,8 @@ async function defaultProfilePreflight(profile: string): Promise<CodexProfilePro
|
||||
// Exported for unit test (B12-T): the REAL async sampling path — the anti-vacuity test drives
|
||||
// this default directly (every other suite injects sync stubs) and asserts the non-blocking
|
||||
// property that the pre-B12 sync implementation violated.
|
||||
export async function defaultListProcesses(): Promise<Array<{ pid: number; ppid: number; command: string }>> {
|
||||
try {
|
||||
const output = await runAsyncSite("codex.runtime.list_processes", async () => {
|
||||
const { stdout } = await execFileAsync("ps", ["-Ao", "pid,ppid,command"], { encoding: "utf-8", maxBuffer: 8 * 1024 * 1024 });
|
||||
return stdout;
|
||||
});
|
||||
return output
|
||||
.split("\n")
|
||||
.slice(1)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const match = line.match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
pid: Number(match[1]),
|
||||
ppid: Number(match[2]),
|
||||
command: match[3] ?? "",
|
||||
};
|
||||
})
|
||||
.filter((row): row is { pid: number; ppid: number; command: string } => row !== null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
export async function defaultListProcesses(): Promise<CodexProcess[]> {
|
||||
return listNativeProcesses();
|
||||
}
|
||||
|
||||
function findCodexDescendantPids(
|
||||
@@ -1496,5 +1482,5 @@ function commandLooksLikeCodex(command: string): boolean {
|
||||
|
||||
function isSkippableCodexUpdatePrompt(paneContent: string): boolean {
|
||||
return paneContent.includes("Update available!")
|
||||
&& paneContent.includes("Skip until next version");
|
||||
&& /^\s*[›>]?\s*3\. Skip until next version\s*$/m.test(paneContent);
|
||||
}
|
||||
@@ -680,7 +680,7 @@ export class BootstrapOrchestrator {
|
||||
|
||||
// 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
|
||||
// launch is partial and the operator needs the same inspection
|
||||
// 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
|
||||
@@ -699,7 +699,7 @@ export class BootstrapOrchestrator {
|
||||
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).`;
|
||||
const message = `${attentionNodes.length} node${attentionNodes.length === 1 ? " requires" : "s require"} attention before becoming interactive. Inspect the affected sessions and reasons before choosing recovery.`;
|
||||
stages.push({
|
||||
stage: "import_rig",
|
||||
status: "blocked",
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { runAsyncSite } from "./sync-site-wrap.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface NativeProcessRow {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
command: string;
|
||||
pgid?: number;
|
||||
tpgid?: number;
|
||||
executableName?: string;
|
||||
startedAt?: string;
|
||||
}
|
||||
|
||||
export type NativeRuntime = "claude-code" | "codex";
|
||||
@@ -29,6 +39,11 @@ function commandUsesExpectedToken(command: string, runtime: NativeRuntime, expec
|
||||
return false;
|
||||
}
|
||||
|
||||
return codexResumeToken(args) === expectedToken;
|
||||
}
|
||||
|
||||
// undefined is a fresh command; null is a resume command without an exact token.
|
||||
function codexResumeToken(args: string[]): string | null | undefined {
|
||||
const topLevelOptionsWithValues = new Set([
|
||||
"-a", "--ask-for-approval", "-c", "--config", "-m", "--model",
|
||||
"-p", "--profile", "-s", "--sandbox",
|
||||
@@ -41,16 +56,16 @@ function commandUsesExpectedToken(command: string, runtime: NativeRuntime, expec
|
||||
if (arg === "resume") resumeIndex = index;
|
||||
break;
|
||||
}
|
||||
if (resumeIndex < 0) return false;
|
||||
if (resumeIndex < 0) return undefined;
|
||||
const resumeArgs = args.slice(resumeIndex + 1);
|
||||
let index = 0;
|
||||
while (index < resumeArgs.length) {
|
||||
const arg = resumeArgs[index]!;
|
||||
if (arg === "--add-dir") { index += 2; continue; }
|
||||
if (arg.startsWith("-")) { index += 1; continue; }
|
||||
return arg === expectedToken;
|
||||
return arg;
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Require a live process in the pane's own lineage whose argv names both the
|
||||
@@ -61,7 +76,8 @@ export function findExactNativeResumeProcess(
|
||||
runtime: string | null,
|
||||
expectedToken: string,
|
||||
): NativeProcessRow | null {
|
||||
if (runtime !== "claude-code" && runtime !== "codex") return null;
|
||||
if (runtime === "codex") return selectCodexProcess(processes, panePid, expectedToken, true)?.process ?? null;
|
||||
if (runtime !== "claude-code") return null;
|
||||
const byParent = new Map<number, NativeProcessRow[]>();
|
||||
for (const process of processes) {
|
||||
const children = byParent.get(process.ppid) ?? [];
|
||||
@@ -81,3 +97,70 @@ export function findExactNativeResumeProcess(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The same OS observation serves menu input, restore proof and periodic identity.
|
||||
* Older callers may carry only pid/ppid/command; that is insufficient positive Codex proof. */
|
||||
export async function listNativeProcesses(): Promise<NativeProcessRow[]> {
|
||||
try {
|
||||
const output = await runAsyncSite("codex.runtime.list_processes", async () => {
|
||||
const { stdout } = await execFileAsync("ps", ["-Ao", "pid,ppid,pgid,tpgid,ucomm,lstart,command"], { encoding: "utf-8", maxBuffer: 8 * 1024 * 1024 });
|
||||
return stdout;
|
||||
});
|
||||
return output.split("\n").slice(1).flatMap((line) => {
|
||||
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)\s+(\w{3}\s+\w{3}\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/);
|
||||
return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), pgid: Number(match[3]), tpgid: Number(match[4]), executableName: match[5]!, startedAt: match[6]!, command: match[7]! }] : [];
|
||||
});
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export type NativeProcessLister = () => NativeProcessRow[] | Promise<NativeProcessRow[]>;
|
||||
export type CodexProcessObservation = { panePid: number; process: NativeProcessRow; fingerprint: string };
|
||||
|
||||
function selectCodexProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false): CodexProcessObservation | null {
|
||||
const byPid = new Map(rows.map((row) => [row.pid, row]));
|
||||
const root = byPid.get(panePid);
|
||||
if (byPid.size !== rows.length || !root?.startedAt || !root.tpgid || root.tpgid <= 0) return null;
|
||||
const matches: { process: NativeProcessRow; chain: NativeProcessRow[] }[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.executableName !== "codex" || executableName(tokens(row.command)[0] ?? "") !== "codex"
|
||||
|| row.pgid !== root.tpgid || row.tpgid !== root.tpgid) continue;
|
||||
const chain: NativeProcessRow[] = [];
|
||||
const visited = new Set<number>();
|
||||
let current: NativeProcessRow | undefined = row;
|
||||
while (current && !visited.has(current.pid) && current.startedAt) {
|
||||
visited.add(current.pid);
|
||||
chain.push(current);
|
||||
if (current.pid === panePid) { matches.push({ process: row, chain }); break; }
|
||||
current = byPid.get(current.ppid);
|
||||
}
|
||||
}
|
||||
if (matches.length !== 1) return null;
|
||||
const { process, chain } = matches[0]!;
|
||||
const resumeToken = codexResumeToken(tokens(process.command).slice(1));
|
||||
if (requireResume && !expectedToken) return null;
|
||||
if ((requireResume || (expectedToken !== undefined && resumeToken !== undefined))
|
||||
&& (!expectedToken || resumeToken !== expectedToken)) return null;
|
||||
return { panePid, process, fingerprint: JSON.stringify(chain.map((row) => [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command])) };
|
||||
}
|
||||
|
||||
export async function observeCodexPaneProcess(input: {
|
||||
target: string;
|
||||
tmux: { getPanePid(target: string): Promise<number | null> };
|
||||
listProcesses?: NativeProcessLister;
|
||||
expectedToken?: string | null;
|
||||
requireResume?: boolean;
|
||||
}): Promise<CodexProcessObservation | null> {
|
||||
try {
|
||||
const pid = await input.tmux.getPanePid(input.target);
|
||||
if (!pid) return null;
|
||||
const rows = await (input.listProcesses ?? listNativeProcesses)();
|
||||
return selectCodexProcess(rows, pid, input.expectedToken, input.requireResume);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function verifyCodexPaneProcess(input: Parameters<typeof observeCodexPaneProcess>[0]): Promise<CodexProcessObservation | null> {
|
||||
const first = await observeCodexPaneProcess(input);
|
||||
if (!first) return null;
|
||||
const second = await observeCodexPaneProcess(input);
|
||||
return second?.fingerprint === first.fingerprint ? second : null;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
} from "./types.js";
|
||||
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
|
||||
import { rebindAndVerifyPaneIdentity } from "./seat-attention-reconciler.js";
|
||||
import { SeatIdentityStore } from "./seat-identity-store.js";
|
||||
import { resolveSnapshotRestoreTopology } from "./restore-topology.js";
|
||||
|
||||
// L3: result shape for runtime-truth reconciliation. A reconciliation that
|
||||
@@ -1375,7 +1376,7 @@ export class RestoreOrchestrator {
|
||||
? ((startupResult.continuityOutcome === "resumed" || nativeContinuityProved) ? "resumed" : baseStatus)
|
||||
: baseStatus;
|
||||
if (finalStatus === "resumed") {
|
||||
return this.finishJoinedResume(node, sessionName, resumeToken);
|
||||
return this.finishJoinedResume(node, sessionName, resumeToken, launchResult?.session.id);
|
||||
}
|
||||
return { nodeId: node.id, logicalId: node.logicalId, status: finalStatus };
|
||||
}
|
||||
@@ -1441,7 +1442,7 @@ export class RestoreOrchestrator {
|
||||
}
|
||||
|
||||
if (baseStatus === "resumed") {
|
||||
return this.finishJoinedResume(node, sessionName, resumeToken);
|
||||
return this.finishJoinedResume(node, sessionName, resumeToken, launchResult?.session.id);
|
||||
}
|
||||
return { nodeId: node.id, logicalId: node.logicalId, status: baseStatus };
|
||||
}
|
||||
@@ -1452,6 +1453,7 @@ export class RestoreOrchestrator {
|
||||
node: SnapshotData["nodes"][number],
|
||||
sessionName: string,
|
||||
resumeToken: string | null,
|
||||
sessionId?: string,
|
||||
): Promise<RestoreNodeResult> {
|
||||
const identity = await rebindAndVerifyPaneIdentity({
|
||||
db: this.db,
|
||||
@@ -1472,6 +1474,22 @@ export class RestoreOrchestrator {
|
||||
error: `Exact native session resumed, but joined restore proof is incomplete: ${identity.detail}. The resumed session was preserved; no replacement was started.`,
|
||||
};
|
||||
}
|
||||
// Legacy resume adapters do not write native metadata. Fill only the
|
||||
// launched row's empty token after proof; never overwrite a hook/operator.
|
||||
if (node.runtime === "codex" && sessionId && resumeToken) {
|
||||
const current = this.db.prepare("SELECT node_id, session_name, status, resume_token FROM sessions WHERE id = ?").get(sessionId) as
|
||||
{ node_id: string; session_name: string; status: string; resume_token: string | null } | undefined;
|
||||
const sameSession = current?.node_id === node.id && current.session_name === sessionName && current.status === "running";
|
||||
const retained = sameSession && (current.resume_token === resumeToken
|
||||
|| (!current.resume_token && this.sessionRegistry.updateResumeToken(sessionId, "codex_id", resumeToken, "scrape")));
|
||||
if (!retained) {
|
||||
const store = new SeatIdentityStore(this.db);
|
||||
const proof = store.getForNode(node.id);
|
||||
if (proof) store.upsert({ ...proof, verdict: "mismatch", reason: "process_identity_mismatch" });
|
||||
return { nodeId: node.id, logicalId: node.logicalId, status: "attention_required",
|
||||
error: "Native resume was observed but its current session metadata conflicts or could not be retained; session preserved." };
|
||||
}
|
||||
}
|
||||
return { nodeId: node.id, logicalId: node.logicalId, status: "resumed" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1441,11 +1441,8 @@ export class PodRigInstantiator {
|
||||
|
||||
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.
|
||||
// tear-down. Inspect the session and reason to distinguish native
|
||||
// decisions from failed/exited runtimes before choosing recovery.
|
||||
const attentionNodes = nodeResults
|
||||
.filter((n) => n.status === "attention_required")
|
||||
.map((n) => ({
|
||||
@@ -1469,7 +1466,7 @@ export class PodRigInstantiator {
|
||||
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).`,
|
||||
message: `${attentionNodes.length} node${attentionNodes.length === 1 ? " requires" : "s require"} attention before becoming interactive. Inspect the affected sessions and reasons before choosing recovery.`,
|
||||
rigId,
|
||||
attentionNodes,
|
||||
};
|
||||
|
||||
@@ -10,10 +10,10 @@ import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { classifyPaneRuntimeMatch } from "./seat-identity-reconciler.js";
|
||||
import { SeatIdentityStore } from "./seat-identity-store.js";
|
||||
import { defaultListProcesses } from "./resume-metadata-refresher.js";
|
||||
import { findExactNativeResumeProcess } from "./native-process-lineage.js";
|
||||
import { verifyCodexPaneProcess, type NativeProcessRow, type NativeProcessLister, findExactNativeResumeProcess } from "./native-process-lineage.js";
|
||||
|
||||
type PaneIdentityTmux = Pick<TmuxAdapter, "listPanes" | "getPanePid" | "getPaneCommand">;
|
||||
type ProcessRow = { pid: number; ppid: number; command: string };
|
||||
type ProcessRow = NativeProcessRow;
|
||||
|
||||
export type PaneIdentityReconcileResult =
|
||||
| { ok: true; pane: string; pid: number; command: string | null }
|
||||
@@ -30,7 +30,7 @@ export async function rebindAndVerifyPaneIdentity(input: {
|
||||
runtime: string | null;
|
||||
expectedResumeToken?: string | null;
|
||||
requireExactResumeLineage?: boolean;
|
||||
listProcesses?: () => Promise<ProcessRow[]>;
|
||||
listProcesses?: NativeProcessLister;
|
||||
now?: () => Date;
|
||||
}): Promise<PaneIdentityReconcileResult> {
|
||||
const observedAt = (input.now ?? (() => new Date()))().toISOString();
|
||||
@@ -69,14 +69,22 @@ export async function rebindAndVerifyPaneIdentity(input: {
|
||||
} catch (error) {
|
||||
return { ok: false, detail: `tmux pane identity lookup failed: ${(error as Error).message}` };
|
||||
}
|
||||
const runtimeMatch = classifyPaneRuntimeMatch(command, input.runtime);
|
||||
let runtimeMatch = classifyPaneRuntimeMatch(command, input.runtime);
|
||||
const normalizedCommand = command?.trim().toLowerCase() ?? "";
|
||||
let lineageMatch: ProcessRow | null = null;
|
||||
const expectedResumeToken = input.expectedResumeToken ?? null;
|
||||
const strictNativeLineage = input.requireExactResumeLineage === true
|
||||
&& expectedResumeToken !== null
|
||||
&& (input.runtime === "claude-code" || input.runtime === "codex");
|
||||
if (pid !== null && runtimeMatch === "match" && strictNativeLineage) {
|
||||
if (input.runtime === "codex") {
|
||||
// A shell/Node label describes the wrapper, not the native occupant.
|
||||
runtimeMatch = "match";
|
||||
const native = await verifyCodexPaneProcess({ target: pane.id, tmux: input.tmux,
|
||||
listProcesses: input.listProcesses, expectedToken: expectedResumeToken,
|
||||
requireResume: input.requireExactResumeLineage === true });
|
||||
const currentPanes = await input.tmux.listPanes(input.sessionName).catch(() => []);
|
||||
if (native?.panePid === pid && currentPanes.length === 1 && currentPanes[0]?.id === pane.id) lineageMatch = native.process;
|
||||
} else if (pid !== null && runtimeMatch === "match" && strictNativeLineage) {
|
||||
try {
|
||||
lineageMatch = findExactNativeResumeProcess(
|
||||
await (input.listProcesses ?? defaultListProcesses)(),
|
||||
@@ -88,10 +96,9 @@ export async function rebindAndVerifyPaneIdentity(input: {
|
||||
// Missing process evidence is ambiguity, never positive identity.
|
||||
}
|
||||
}
|
||||
const runtimeAmbiguous = runtimeMatch === "match" && (strictNativeLineage
|
||||
const runtimeAmbiguous = input.runtime === "codex" ? lineageMatch === null : runtimeMatch === "match" && (strictNativeLineage
|
||||
? lineageMatch === null
|
||||
: (input.runtime === "claude-code" && !normalizedCommand.includes("claude"))
|
||||
|| (input.runtime === "codex" && !normalizedCommand.includes("codex")));
|
||||
: input.runtime === "claude-code" && !normalizedCommand.includes("claude"));
|
||||
const verdict: SeatIdentityVerdict = {
|
||||
nodeId: input.nodeId,
|
||||
verdict: pid === null
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observeCodexPaneProcess, listNativeProcesses, type NativeProcessLister, type CodexProcessObservation } from "./native-process-lineage.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
@@ -21,7 +22,8 @@ const SHELL_COMMANDS = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* Classify the pane's foreground command against the registered runtime.
|
||||
* Legacy short-command classifier. Codex callers additionally require the
|
||||
* shared native-process proof; this label alone is not positive Codex identity.
|
||||
*
|
||||
* Deliberately LENIENT to preserve the slice-15 no-false-green-for-live-seats
|
||||
* invariant: a genuinely-live claude/codex TUI usually reports its host
|
||||
@@ -66,12 +68,14 @@ interface RunningSeatRow {
|
||||
runtime: string | null;
|
||||
session_name: string;
|
||||
tmux_pane: string | null;
|
||||
resume_token?: string | null;
|
||||
}
|
||||
|
||||
export interface SeatIdentityReconcilerDeps {
|
||||
db: Database.Database;
|
||||
tmux: Pick<TmuxAdapter, "listSessions" | "getPanePid" | "getPaneCommand">;
|
||||
now?: () => Date;
|
||||
listProcesses?: NativeProcessLister;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +94,7 @@ export class SeatIdentityReconciler {
|
||||
private readonly tmux: SeatIdentityReconcilerDeps["tmux"];
|
||||
private readonly now: () => Date;
|
||||
private readonly store: SeatIdentityStore;
|
||||
private readonly listProcesses: NativeProcessLister;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(deps: SeatIdentityReconcilerDeps) {
|
||||
@@ -97,12 +102,13 @@ export class SeatIdentityReconciler {
|
||||
this.tmux = deps.tmux;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
this.store = new SeatIdentityStore(deps.db);
|
||||
this.listProcesses = deps.listProcesses ?? listNativeProcesses;
|
||||
}
|
||||
|
||||
private runningSeats(): RunningSeatRow[] {
|
||||
return this.db.prepare(`
|
||||
SELECT n.id as node_id, n.runtime as runtime,
|
||||
s.session_name as session_name, b.tmux_pane as tmux_pane
|
||||
s.session_name as session_name, b.tmux_pane as tmux_pane, s.resume_token as resume_token
|
||||
FROM nodes n
|
||||
JOIN sessions s ON s.node_id = n.id
|
||||
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
|
||||
@@ -125,7 +131,7 @@ export class SeatIdentityReconciler {
|
||||
// One tmux availability probe per poll. If tmux is entirely unreachable
|
||||
// (throws) OR reports zero live sessions while we have running seats in
|
||||
// the DB, treat it as a transient tmux blip and record `tmux_unavailable`
|
||||
// for every seat — NEVER down-rank a live fleet on an infra hiccup.
|
||||
// for legacy runtimes. Codex remains non-green without native evidence.
|
||||
let liveSessions: Set<string> | null = null;
|
||||
try {
|
||||
const sessions = await this.tmux.listSessions();
|
||||
@@ -140,12 +146,26 @@ export class SeatIdentityReconciler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Two fresh process snapshots per sweep, not two ps calls per seat. Each
|
||||
// phase observes every bound pane; the second begins after the first ends.
|
||||
const codexSeats = seats.filter((seat) => seat.runtime === "codex" && seat.tmux_pane && liveSessions.has(seat.session_name));
|
||||
const sample = async () => {
|
||||
let snapshot: ReturnType<NativeProcessLister> | undefined;
|
||||
return Promise.all(codexSeats.map((seat) => observeCodexPaneProcess({
|
||||
target: seat.tmux_pane!, tmux: this.tmux, expectedToken: seat.resume_token,
|
||||
listProcesses: () => snapshot ??= this.listProcesses(),
|
||||
})));
|
||||
};
|
||||
const first = await sample();
|
||||
const second = await sample();
|
||||
const codexProofs = new Map(codexSeats.map((seat, index) => [seat.node_id,
|
||||
first[index] && first[index]?.fingerprint === second[index]?.fingerprint ? second[index]! : null]));
|
||||
for (const seat of seats) {
|
||||
try {
|
||||
this.store.upsert(await this.computeVerdict(seat, liveSessions, observedAt));
|
||||
this.store.upsert(await this.computeVerdict(seat, liveSessions, observedAt, codexProofs.get(seat.node_id) ?? null));
|
||||
} catch {
|
||||
// A single seat's tmux failure must not crash the loop; record it as
|
||||
// an unknown (non-down-ranking) observation.
|
||||
// unavailable observation (non-green for Codex).
|
||||
this.store.upsert(this.tmuxUnavailableVerdict(seat, observedAt));
|
||||
}
|
||||
}
|
||||
@@ -154,7 +174,7 @@ export class SeatIdentityReconciler {
|
||||
private tmuxUnavailableVerdict(seat: RunningSeatRow, observedAt: string): SeatIdentityVerdict {
|
||||
return {
|
||||
nodeId: seat.node_id,
|
||||
verdict: "tmux_unavailable",
|
||||
verdict: seat.runtime === "codex" ? "mismatch" : "tmux_unavailable",
|
||||
evidenceSource: null,
|
||||
reason: "tmux_unavailable",
|
||||
evidence: { registeredPane: seat.tmux_pane, observedPid: null, observedCommand: null, matchedLayer: null },
|
||||
@@ -167,6 +187,7 @@ export class SeatIdentityReconciler {
|
||||
seat: RunningSeatRow,
|
||||
liveSessions: Set<string>,
|
||||
observedAt: string,
|
||||
native: CodexProcessObservation | null,
|
||||
): Promise<SeatIdentityVerdict> {
|
||||
const base = {
|
||||
nodeId: seat.node_id,
|
||||
@@ -176,7 +197,7 @@ export class SeatIdentityReconciler {
|
||||
|
||||
// A null binding pane has two distinct causes. When the target session is
|
||||
// absent, that is a down-ranking missing-session fact. When it is live,
|
||||
// only the binding is absent: named, but non-down-ranking.
|
||||
// only the binding is absent: named, and non-green for Codex.
|
||||
if (!seat.tmux_pane) {
|
||||
if (!liveSessions.has(seat.session_name)) {
|
||||
return {
|
||||
@@ -189,7 +210,7 @@ export class SeatIdentityReconciler {
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
verdict: "binding_absent",
|
||||
verdict: seat.runtime === "codex" ? "mismatch" : "binding_absent",
|
||||
evidenceSource: "tmux_session",
|
||||
reason: "binding_pane_missing",
|
||||
evidence: { registeredPane: null, observedPid: null, observedCommand: null, matchedLayer: null },
|
||||
@@ -211,6 +232,14 @@ export class SeatIdentityReconciler {
|
||||
}
|
||||
|
||||
const command = await this.tmux.getPaneCommand(seat.tmux_pane);
|
||||
if (seat.runtime === "codex") {
|
||||
return {
|
||||
...base, verdict: native?.panePid === pid ? "verified" : "mismatch",
|
||||
evidenceSource: "pane_process", reason: native?.panePid === pid ? null : "process_identity_ambiguous",
|
||||
evidence: { registeredPane: seat.tmux_pane, observedPid: native?.process.pid ?? pid,
|
||||
observedCommand: native?.process.command ?? command, matchedLayer: native?.panePid === pid ? 1 : null },
|
||||
};
|
||||
}
|
||||
const match = classifyPaneRuntimeMatch(command, seat.runtime);
|
||||
if (match === "mismatch") {
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { NativeProcessLister } from "./native-process-lineage.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { RigRepository } from "./rig-repository.js";
|
||||
import type { SessionRegistry } from "./session-registry.js";
|
||||
@@ -43,6 +44,7 @@ export interface SeatLifecycleDeps {
|
||||
sessionRegistry: SessionRegistry;
|
||||
eventBus: EventBus;
|
||||
tmuxAdapter: TmuxAdapter;
|
||||
listProcesses?: NativeProcessLister;
|
||||
nodeLauncher?: NodeLauncher;
|
||||
startupOrchestrator?: StartupOrchestrator;
|
||||
runtimeAdapters?: Record<string, RuntimeAdapter>;
|
||||
@@ -151,6 +153,7 @@ export class SeatLifecycleService {
|
||||
private readonly sessionRegistry: SessionRegistry;
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly tmuxAdapter: TmuxAdapter;
|
||||
private readonly listProcesses?: NativeProcessLister;
|
||||
private readonly nodeLauncher: NodeLauncher | null;
|
||||
private readonly startupOrchestrator: StartupOrchestrator | null;
|
||||
private readonly runtimeAdapters: Record<string, RuntimeAdapter>;
|
||||
@@ -166,6 +169,7 @@ export class SeatLifecycleService {
|
||||
this.sessionRegistry = deps.sessionRegistry;
|
||||
this.eventBus = deps.eventBus;
|
||||
this.tmuxAdapter = deps.tmuxAdapter;
|
||||
this.listProcesses = deps.listProcesses;
|
||||
this.nodeLauncher = deps.nodeLauncher ?? null;
|
||||
this.startupOrchestrator = deps.startupOrchestrator ?? null;
|
||||
this.runtimeAdapters = deps.runtimeAdapters ?? {};
|
||||
@@ -610,6 +614,7 @@ export class SeatLifecycleService {
|
||||
sessionName: canonicalSessionName,
|
||||
runtime: node.runtime,
|
||||
expectedResumeToken: this.sessionResumeToken(launch.session.id),
|
||||
listProcesses: this.listProcesses,
|
||||
});
|
||||
const attentionRequired = !startupResult.ok || !identity.ok;
|
||||
if (!identity.ok) this.sessionRegistry.updateStartupStatus(launch.session.id, "attention_required");
|
||||
|
||||
@@ -60,10 +60,10 @@ export function buildAttentionResponse(result: {
|
||||
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.`,
|
||||
consequence: `Rig ${rigIdDisplay} is created and listable via \`rig ps\`. Members marked attention_required have not been proven interactive; a runtime may be waiting for input or may have exited.`,
|
||||
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}.`,
|
||||
? `Inspect the affected session and its reported reason before choosing recovery: ${attachHintText}.`
|
||||
: `Inspect each affected session listed in attentionNodes and its reported reason before choosing recovery: ${attachHintText}.`,
|
||||
},
|
||||
attentionNodes: detail.attentionNodes,
|
||||
};
|
||||
@@ -414,7 +414,7 @@ upRoutes.post("/", async (c) => {
|
||||
// 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
|
||||
// affected sessions and inspect their actual state. The rig + sessions are
|
||||
// PRESERVED on disk; `rig ps` lists them. PRD HG-4.
|
||||
const attentionResponse = buildAttentionResponse(result);
|
||||
if (attentionResponse) {
|
||||
|
||||
@@ -1384,8 +1384,10 @@ edges: []
|
||||
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 }> };
|
||||
const detail = importStage!.detail as { code: string; message: string; attentionNodes: Array<{ logicalId: string; sessionName: string; evidence?: string }> };
|
||||
expect(detail.code).toBe("attention_required");
|
||||
expect(detail.message).toMatch(/inspect/i);
|
||||
expect(detail.message).not.toMatch(/approve and resume|NOT failed/);
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { defaultListProcesses } from "../src/adapters/codex-runtime-adapter.js";
|
||||
|
||||
let scratch: string | undefined;
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
if (scratch) fs.rmSync(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("reads foreground identity through the real async executable and parser path", async () => {
|
||||
scratch = fs.mkdtempSync(path.join(os.tmpdir(), "codex-process-read-"));
|
||||
// A hermetic ps executable; no provider, tmux or daemon is launched.
|
||||
const output = [
|
||||
"PID PPID PGID TPGID UCOMM LSTART COMMAND",
|
||||
"101 1 101 102 zsh Sat Jan 1 12:00:00 2000 -zsh",
|
||||
"102 101 102 102 node Sat Jan 1 12:00:00 2000 node /opt/bin/codex -C /project",
|
||||
"103 102 102 102 codex Sat Jan 1 12:00:00 2000 /opt/vendor/bin/codex -C /project",
|
||||
"104 1 104 -1 node Sat Jan 1 12:00:00 2000 node /tmp/other.js --label codex",
|
||||
"broken row",
|
||||
"105 1 105 0",
|
||||
];
|
||||
const quote = (s: string) => `'${s.replaceAll("'", "'\\''")}'`;
|
||||
fs.writeFileSync(path.join(scratch, "ps"), "#!/bin/sh\n"
|
||||
+ `printf '%s\\n' "$@" > ${quote(path.join(scratch, "args"))}\n`
|
||||
+ `printf '%s\\n' ${output.map(quote).join(" ")}\n`, { mode: 0o755 });
|
||||
vi.stubEnv("PATH", scratch);
|
||||
const rows = await defaultListProcesses();
|
||||
expect(fs.readFileSync(path.join(scratch, "args"), "utf8")).toBe("-Ao\npid,ppid,pgid,tpgid,ucomm,lstart,command\n");
|
||||
expect(rows).toEqual([
|
||||
{ pid: 101, ppid: 1, pgid: 101, tpgid: 102, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000", command: "-zsh" },
|
||||
{ pid: 102, ppid: 101, pgid: 102, tpgid: 102, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /opt/bin/codex -C /project" },
|
||||
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/vendor/bin/codex -C /project" },
|
||||
{ pid: 104, ppid: 1, pgid: 104, tpgid: -1, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /tmp/other.js --label codex" },
|
||||
]);
|
||||
fs.writeFileSync(path.join(scratch, "ps"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
expect(await defaultListProcesses()).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads this test process with the installed ps columns, without starting a provider", async () => {
|
||||
const rows = await defaultListProcesses();
|
||||
const self = rows.find((r) => r.pid === process.pid);
|
||||
expect(self).toBeDefined();
|
||||
expect(self!.ppid).toBe(process.ppid);
|
||||
expect(self!.pgid).toBeGreaterThan(0);
|
||||
expect(Number.isInteger(self!.tpgid)).toBe(true);
|
||||
expect(self!.executableName).toBeTruthy();
|
||||
expect(self!.command).toBeTruthy();
|
||||
expect(self!.startedAt).toMatch(/\d{2}:\d{2}:\d{2}.*\d{4}/);
|
||||
});
|
||||
@@ -554,7 +554,7 @@ describe("Codex runtime adapter", () => {
|
||||
expect(sendText).toHaveBeenCalledWith("r01-qa", expectedFreshLaunchCommand({ queueRoot: null }));
|
||||
});
|
||||
|
||||
it("launchHarness skips the non-mutating Codex update prompt before capturing a fresh thread id", async () => {
|
||||
it("launchHarness skips the Codex update prompt with one control key before capturing a fresh thread id", async () => {
|
||||
const initialShell = [
|
||||
expectedFreshLaunchCommand(),
|
||||
"admin@host project %",
|
||||
@@ -571,9 +571,10 @@ describe("Codex runtime adapter", () => {
|
||||
getPaneCommand: vi.fn()
|
||||
.mockResolvedValueOnce("zsh")
|
||||
.mockResolvedValue("codex"),
|
||||
capturePaneContent: vi.fn()
|
||||
capturePaneScreen: vi.fn()
|
||||
.mockResolvedValueOnce(initialShell)
|
||||
.mockResolvedValueOnce(updatePrompt)
|
||||
.mockResolvedValueOnce(updatePrompt)
|
||||
.mockResolvedValue("OpenAI Codex (v0.120.0)\n› Ask Codex to do anything"),
|
||||
getPanePid: vi.fn(async () => 900),
|
||||
});
|
||||
@@ -581,8 +582,8 @@ describe("Codex runtime adapter", () => {
|
||||
tmux,
|
||||
fsOps: mockFs(),
|
||||
listProcesses: () => [
|
||||
{ pid: 900, ppid: 1, command: "-zsh" },
|
||||
{ pid: 901, ppid: 900, command: "codex" },
|
||||
{ pid: 900, ppid: 1, command: "-zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
],
|
||||
readThreadIdByPid: (pid) => pid === 901 ? "019d45bc-117d-78a3-a4ad-6fb186e5a86d" : undefined,
|
||||
sleep: async () => {},
|
||||
@@ -599,12 +600,11 @@ describe("Codex runtime adapter", () => {
|
||||
const sendText = tmux.sendText as ReturnType<typeof vi.fn>;
|
||||
expect(sendText.mock.calls).toEqual([
|
||||
["r01-qa", expectedFreshLaunchCommand()],
|
||||
["r01-qa", "3"],
|
||||
]);
|
||||
const sendKeys = tmux.sendKeys as ReturnType<typeof vi.fn>;
|
||||
expect(sendKeys.mock.calls).toEqual([
|
||||
["r01-qa", ["Enter"]],
|
||||
["r01-qa", ["Enter"]],
|
||||
["r01-qa", ["3"]],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -644,8 +644,12 @@ describe("Codex runtime adapter", () => {
|
||||
"Press enter to continue",
|
||||
].join("\n");
|
||||
const tmux = mockTmux({
|
||||
getPaneCommand: vi.fn(async () => "zsh"),
|
||||
capturePaneContent: vi.fn()
|
||||
getPaneCommand: vi.fn()
|
||||
.mockResolvedValueOnce("zsh").mockResolvedValueOnce("zsh")
|
||||
.mockResolvedValueOnce("zsh").mockResolvedValueOnce("zsh")
|
||||
.mockResolvedValueOnce("zsh").mockResolvedValueOnce("zsh")
|
||||
.mockResolvedValue("codex"),
|
||||
capturePaneScreen: vi.fn()
|
||||
.mockResolvedValueOnce(initialShell)
|
||||
.mockResolvedValueOnce(initialShell)
|
||||
.mockResolvedValueOnce(initialShell)
|
||||
@@ -653,6 +657,7 @@ describe("Codex runtime adapter", () => {
|
||||
.mockResolvedValueOnce(initialShell)
|
||||
.mockResolvedValueOnce(initialShell)
|
||||
.mockResolvedValueOnce(updatePrompt)
|
||||
.mockResolvedValueOnce(updatePrompt)
|
||||
.mockResolvedValue("OpenAI Codex (v0.120.0)\n› Ask Codex to do anything"),
|
||||
getPanePid: vi.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
@@ -662,8 +667,8 @@ describe("Codex runtime adapter", () => {
|
||||
tmux,
|
||||
fsOps: mockFs(),
|
||||
listProcesses: () => [
|
||||
{ pid: 900, ppid: 1, command: "-zsh" },
|
||||
{ pid: 901, ppid: 900, command: "codex" },
|
||||
{ pid: 900, ppid: 1, command: "-zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 901, ppid: 900, command: "codex", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
],
|
||||
readThreadIdByPid: (pid) => pid === 901 ? "019d45bc-117d-78a3-a4ad-6fb186e5a86d" : undefined,
|
||||
sleep: async () => {},
|
||||
@@ -680,8 +685,8 @@ describe("Codex runtime adapter", () => {
|
||||
const sendText = tmux.sendText as ReturnType<typeof vi.fn>;
|
||||
expect(sendText.mock.calls).toEqual([
|
||||
["r01-qa", expectedFreshLaunchCommand()],
|
||||
["r01-qa", "3"],
|
||||
]);
|
||||
expect(tmux.sendKeys).toHaveBeenLastCalledWith("r01-qa", ["3"]);
|
||||
});
|
||||
|
||||
it("uses the visible conversation rather than dismissed loading/review scrollback", async () => {
|
||||
@@ -930,7 +935,7 @@ describe("Codex runtime adapter", () => {
|
||||
// ensureCodexFeatureFlag) + activity-hook-rip-proof.test.ts (negative
|
||||
// assertions on adapter symbol absence + endpoint-stays).
|
||||
|
||||
it("launchHarness skips the non-mutating Codex update prompt during resume verification", async () => {
|
||||
it("launchHarness skips the Codex update prompt with one control key during resume verification", async () => {
|
||||
const updatePrompt = [
|
||||
"✨ Update available! 0.120.0 -> 0.121.0",
|
||||
"› 1. Update now (runs `npm install -g @openai/codex`)",
|
||||
@@ -939,11 +944,15 @@ describe("Codex runtime adapter", () => {
|
||||
"Press enter to continue",
|
||||
].join("\n");
|
||||
const tmux = mockTmux({
|
||||
capturePaneContent: vi.fn()
|
||||
getPanePid: vi.fn(async () => 901),
|
||||
capturePaneScreen: vi.fn()
|
||||
.mockResolvedValueOnce(updatePrompt)
|
||||
.mockResolvedValueOnce(updatePrompt)
|
||||
.mockResolvedValue("OpenAI Codex (v0.120.0)\n› Ask Codex to do anything"),
|
||||
});
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {} });
|
||||
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {},
|
||||
listProcesses: () => [{ pid: 901, ppid: 1, pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "codex" }],
|
||||
});
|
||||
|
||||
const result = await adapter.launchHarness(makeBinding(), { name: "dev-qa@test-rig", resumeToken: "sess-456" });
|
||||
|
||||
@@ -951,12 +960,11 @@ describe("Codex runtime adapter", () => {
|
||||
const sendText = tmux.sendText as ReturnType<typeof vi.fn>;
|
||||
expect(sendText.mock.calls).toEqual([
|
||||
["r01-qa", expectedResumeCommand()],
|
||||
["r01-qa", "3"],
|
||||
]);
|
||||
const sendKeys = tmux.sendKeys as ReturnType<typeof vi.fn>;
|
||||
expect(sendKeys.mock.calls).toEqual([
|
||||
["r01-qa", ["Enter"]],
|
||||
["r01-qa", ["Enter"]],
|
||||
["r01-qa", ["3"]],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1186,7 +1194,7 @@ describe("Codex runtime adapter", () => {
|
||||
|
||||
// Guard against breaking the working auto-dismiss: a skippable update gate
|
||||
// still auto-dismisses and continues to success (covered end-to-end by
|
||||
// "launchHarness skips the non-mutating Codex update prompt during resume
|
||||
// "launchHarness skips the Codex update prompt with one control key during resume
|
||||
// verification" above) — only UNRESOLVED gates fail loudly.
|
||||
|
||||
it("deliverStartup pre-seeds Codex trust for the managed project", async () => {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CodexRuntimeAdapter, type CodexAdapterFsOps } from "../src/adapters/codex-runtime-adapter.js";
|
||||
import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
|
||||
|
||||
const MENU = [
|
||||
"✨ Update available! 0.155.1 -> 0.156.1",
|
||||
"› 1. Update now (runs `npm install -g @openai/codex`)",
|
||||
" 2. Skip", " 3. Skip until next version", "Press enter to continue",
|
||||
].join("\n");
|
||||
const READY = "OpenAI Codex (v0.155.1)\n› Ask Codex to do anything";
|
||||
const binding = {
|
||||
id: "b", nodeId: "n", tmuxSession: "checker@test", tmuxWindow: null, tmuxPane: null,
|
||||
cmuxWorkspace: null, cmuxSurface: null, updatedAt: "", cwd: "/private-test",
|
||||
} satisfies NodeBinding;
|
||||
|
||||
// Modeled receiver, NOT native execution. Pinned upstream be2951ea:
|
||||
// update_prompt.rs:57-68 ignores Paste; :121-158 Key3 selects/submits
|
||||
// DontRemind immediately; :74-85 persists dismissal or returns RunUpdate.
|
||||
// Drive real TmuxAdapter serialization into this model, not a sequence of
|
||||
// ready screenshots that would pass even if the wrong input were sent.
|
||||
type ProcessRow = { pid: number; ppid: number; command: string; pgid?: number; tpgid?: number; executableName?: string; startedAt?: string };
|
||||
const native = "/opt/codex/vendor/aarch64-apple-darwin/bin/codex";
|
||||
// Synthetic shell -> Node -> native ancestry, including a background helper.
|
||||
// Paths, PIDs and start times are invented; only the process relationships matter.
|
||||
// The receiver below models the update menu; this is not native execution.
|
||||
const ordinaryRows: ProcessRow[] = [
|
||||
{ pid: 2001, ppid: 2000, pgid: 2001, tpgid: 2002, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000", command: "-zsh" },
|
||||
{ pid: 2002, ppid: 2001, pgid: 2002, tpgid: 2002, executableName: "bash", startedAt: "Sat Jan 1 12:00:00 2000", command: "/bin/sh /tmp/test-launch/openrig-tmux-send.txt" },
|
||||
{ pid: 2003, ppid: 2002, pgid: 2002, tpgid: 2002, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /opt/test-provider/bin/codex -s workspace-write -C /workspace/sample-project --add-dir /workspace/sample-project/.git --add-dir /tmp/test-instance/shared-docs/rigs/first-project/state/dev -m fixture-model" },
|
||||
{ pid: 2004, ppid: 2003, pgid: 2002, tpgid: 2002, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/test-provider/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin/codex -s workspace-write -C /workspace/sample-project --add-dir /workspace/sample-project/.git --add-dir /tmp/test-instance/shared-docs/rigs/first-project/state/dev -m fixture-model" },
|
||||
{ pid: 2005, ppid: 2004, pgid: 2005, tpgid: 2002, executableName: "codex-code-mode-", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/test-provider/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin/codex-code-mode-host" },
|
||||
];
|
||||
const processRows = (shape: "wrapper" | "exec-wrapper" | "native"): ProcessRow[] => {
|
||||
if (shape === "native") return [{ pid: 101, ppid: 1, command: native, pgid: 101, tpgid: 101, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" }];
|
||||
if (shape === "exec-wrapper") return [
|
||||
{ pid: 101, ppid: 1, command: "node /opt/bin/codex", pgid: 101, tpgid: 101, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 102, ppid: 101, command: native, pgid: 101, tpgid: 101, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
];
|
||||
return [
|
||||
{ pid: 101, ppid: 1, command: "-zsh", pgid: 101, tpgid: 102, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 102, ppid: 101, command: "node /opt/bin/codex", pgid: 102, tpgid: 102, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 103, ppid: 102, command: native, pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
];
|
||||
};
|
||||
function fixture(options: { delay?: number; beforeMenu?: number; failInput?: boolean; screen?: string; command?: string; panePid?: number; processes?: ProcessRow[]; shape?: "wrapper" | "exec-wrapper" | "native" } = {}) {
|
||||
let selected = false;
|
||||
let ticks = 0;
|
||||
let updates = 0;
|
||||
let dismissals = 0;
|
||||
let paste = "";
|
||||
const commands: string[] = [];
|
||||
const leaked: string[] = [];
|
||||
const ready = () => selected && ticks >= (options.delay ?? 0);
|
||||
const screen = () => options.screen ?? (ticks < (options.beforeMenu ?? 0) ? "Starting Codex..." : ready() ? READY : MENU);
|
||||
const tmux = new TmuxAdapter(async (cmd) => {
|
||||
commands.push(cmd);
|
||||
if (cmd.includes("paste-buffer")) {
|
||||
expect(cmd).toContain("-p");
|
||||
// Native update menu ignores bracketed Paste, including Paste("3").
|
||||
if (ready()) leaked.push(paste);
|
||||
}
|
||||
if (cmd.startsWith("tmux send-keys")) {
|
||||
if (options.failInput) throw new Error("input transport failed");
|
||||
if (selected) leaked.push(cmd);
|
||||
else if (cmd.endsWith(" '3'")) { selected = true; dismissals++; }
|
||||
else if (cmd.endsWith(" 'Enter'")) updates++;
|
||||
}
|
||||
return "";
|
||||
}, {
|
||||
writeFile: async (_path, content) => { paste = content; },
|
||||
unlink: async () => {}, tmpName: () => "/mock/input", bufferName: () => "input",
|
||||
});
|
||||
vi.spyOn(tmux, "sendShellCommand").mockResolvedValue({ ok: true });
|
||||
vi.spyOn(tmux, "getPaneCommand").mockImplementation(async () => options.command ?? "codex");
|
||||
vi.spyOn(tmux, "capturePaneScreen").mockImplementation(async () => screen());
|
||||
vi.spyOn(tmux, "capturePaneContent").mockResolvedValue(MENU); // stale scrollback
|
||||
vi.spyOn(tmux, "hasSession").mockResolvedValue(true);
|
||||
vi.spyOn(tmux, "getPanePid").mockResolvedValue(options.panePid ?? 101);
|
||||
const fsOps: CodexAdapterFsOps = {
|
||||
readFile: () => { throw new Error("absent"); }, writeFile: () => {},
|
||||
exists: () => false, mkdirp: () => {}, listFiles: () => [], homedir: "/mock/home",
|
||||
};
|
||||
const listProcesses = vi.fn(() => options.processes ?? processRows(options.shape ?? "wrapper"));
|
||||
const adapter = new CodexRuntimeAdapter({
|
||||
tmux, fsOps, sleep: async () => { ticks++; },
|
||||
listProcesses,
|
||||
readThreadIdByPid: () => ready() ? "new-thread" : undefined,
|
||||
});
|
||||
return { adapter, tmux, listProcesses, commands, leaked, ready, counts: () => ({ updates, dismissals }),
|
||||
reset: () => { selected = false; ticks = 0; } };
|
||||
}
|
||||
|
||||
const paths = [
|
||||
{ name: "fresh", opts: { name: "checker@test" } },
|
||||
{ name: "resume", opts: { name: "checker@test", resumeToken: "original-thread" } },
|
||||
{ name: "fork", opts: { name: "checker@test", forkSource: { kind: "native_id" as const, value: "parent-thread" } } },
|
||||
];
|
||||
|
||||
describe.each(paths)("Codex update input: $name", ({ opts }) => {
|
||||
it.each([0, 4, 100])("sends one real key, no Enter or retry, with %i delayed ticks", async (delay) => {
|
||||
const f = fixture({ delay });
|
||||
const result = await f.adapter.launchHarness(binding, opts);
|
||||
expect(f.commands).toEqual(["tmux send-keys -t 'checker@test' '3'"]);
|
||||
expect(f.counts()).toEqual({ updates: 0, dismissals: 1 });
|
||||
expect(f.leaked).toEqual([]);
|
||||
expect((await f.adapter.checkReady(binding)).ready).toBe(f.ready());
|
||||
if (f.ready()) expect(result.ok).toBe(true);
|
||||
else if (opts.resumeToken) expect(result).toMatchObject({ ok: false, recovery: "attention_required" });
|
||||
});
|
||||
|
||||
it.each([0, 9])("reports input failure without retry when the menu arrives after %i ticks", async (beforeMenu) => {
|
||||
const f = fixture({ failInput: true, beforeMenu });
|
||||
const result = await f.adapter.launchHarness(binding, opts);
|
||||
expect(result).toMatchObject({ ok: false, recovery: "attention_required" });
|
||||
if (!result.ok) expect(result.error).toContain("input transport failed");
|
||||
expect(f.commands).toEqual(["tmux send-keys -t 'checker@test' '3'"]);
|
||||
expect(f.counts()).toEqual({ updates: 0, dismissals: 0 });
|
||||
expect((await f.adapter.checkReady(binding)).ready).toBe(false);
|
||||
});
|
||||
|
||||
it("handles a menu first appearing during later thread/resume polls", async () => {
|
||||
const f = fixture({ beforeMenu: 9 });
|
||||
const result = await f.adapter.launchHarness(binding, opts);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(f.commands).toEqual(["tmux send-keys -t 'checker@test' '3'"]);
|
||||
expect(f.counts()).toEqual({ updates: 0, dismissals: 1 });
|
||||
expect(f.leaked).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not reopen update automation after seeing the conversation", async () => {
|
||||
const f = fixture();
|
||||
vi.mocked(f.tmux.capturePaneScreen).mockResolvedValueOnce(READY).mockResolvedValue(MENU);
|
||||
await f.adapter.launchHarness(binding, opts);
|
||||
expect(f.commands).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ screen: READY },
|
||||
{ screen: MENU.replace("3. Skip", "4. Skip") },
|
||||
{ screen: "Update available! Updating Codex..." },
|
||||
{ screen: MENU, command: "zsh", processes: [{ pid: 101, ppid: 1, command: "-zsh", pgid: 101, tpgid: 101, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" }] },
|
||||
{ screen: "Unknown screen" },
|
||||
])("leaves unknown/changed/exited or already-ready screens alone: %j", async (options) => {
|
||||
const f = fixture(options);
|
||||
await f.adapter.launchHarness(binding, opts);
|
||||
expect(f.commands).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["wrapper", "exec-wrapper", "native"] as const)("recognizes the live foreground %s identity", async (shape) => {
|
||||
const f = fixture({ shape, command: shape === "native" ? "codex" : "node" });
|
||||
await f.adapter.launchHarness(binding, opts);
|
||||
expect(f.commands).toEqual(["tmux send-keys -t 'checker@test' '3'"]);
|
||||
expect(f.counts()).toEqual({ updates: 0, dismissals: 1 });
|
||||
});
|
||||
|
||||
it("checks synthetic shell-owned ancestry before selecting one key", async () => {
|
||||
const f = fixture({ command: "bash", panePid: 2001, processes: ordinaryRows });
|
||||
await f.adapter.launchHarness(binding, opts);
|
||||
expect(f.commands).toEqual(["tmux send-keys -t 'checker@test' '3'"]);
|
||||
expect(f.listProcesses.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(f.counts()).toEqual({ updates: 0, dismissals: 1 });
|
||||
expect(f.leaked).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "background native", rows: ordinaryRows.map(r => r.pid === 2004 ? { ...r, pgid: 9999 } : r) },
|
||||
{ name: "unrelated native", rows: ordinaryRows.map(r => r.pid === 2004 ? { ...r, ppid: 1 } : r) },
|
||||
{ name: "exited native with stale menu", rows: ordinaryRows.filter(r => r.pid !== 2004) },
|
||||
])("rejects synthetic shell ancestry with $name", async ({ rows }) => {
|
||||
const f = fixture({ command: "bash", panePid: 2001, processes: rows });
|
||||
await f.adapter.launchHarness(binding, opts);
|
||||
expect(f.commands).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "no process rows", rows: [] },
|
||||
{ name: "native name with unrelated executable argument zero", rows: [{pid:101,ppid:1,command:"/bin/echo codex",pgid:101,tpgid:101,executableName:"codex", startedAt: "Sat Jan 1 12:00:00 2000"}] },
|
||||
{ name: "argument named codex", rows: [{pid:101,ppid:1,command:"node other.js --label codex",pgid:101,tpgid:101,executableName:"node", startedAt: "Sat Jan 1 12:00:00 2000"}] },
|
||||
{ name: "native name only in arguments", rows: [{pid:101,ppid:1,command:"/bin/echo codex",pgid:101,tpgid:101,executableName:"echo", startedAt: "Sat Jan 1 12:00:00 2000"}] },
|
||||
{ name: "background Codex", rows: processRows("wrapper").map(r=>({...r,tpgid:500})) },
|
||||
{ name: "missing group", rows: processRows("wrapper").map(({pgid,...r})=>r) },
|
||||
{ name: "no terminal foreground", rows: processRows("wrapper").map(r=>({...r,tpgid:-1})) },
|
||||
{ name: "conflicting terminal group", rows: processRows("wrapper").map(r=>r.pid===103?{...r,tpgid:500}:r) },
|
||||
{ name: "another pane", rows: processRows("wrapper").map(r=>r.pid===103?{...r,ppid:999}:r) },
|
||||
{ name: "kernel name contradicts argv", rows: processRows("wrapper").map(r=>r.pid===103?{...r,executableName:"node", startedAt: "Sat Jan 1 12:00:00 2000"}:r) },
|
||||
{ name: "ambiguous native processes", rows: [...processRows("wrapper"),{pid:104,ppid:102,command:native,pgid:102,tpgid:102,executableName:"codex", startedAt: "Sat Jan 1 12:00:00 2000"}] },
|
||||
])("does not choose Skip with $name", async ({rows}) => {
|
||||
for (const command of ["node", "codex", "bash"]) {
|
||||
const f=fixture({command,processes:rows});
|
||||
await f.adapter.launchHarness(binding,opts);
|
||||
expect(f.commands).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["missing pane", "missing pane command", "failed process read", "missing current screen"])("refuses %s", async (failure) => {
|
||||
const f = fixture({ command: "node" });
|
||||
if (failure === "missing pane") vi.mocked(f.tmux.getPanePid).mockResolvedValue(null);
|
||||
if (failure === "missing pane command") vi.mocked(f.tmux.getPaneCommand).mockResolvedValue(null);
|
||||
if (failure === "failed process read") f.listProcesses.mockImplementation(() => { throw Error("ps unavailable"); });
|
||||
if (failure === "missing current screen") vi.mocked(f.tmux.capturePaneScreen).mockResolvedValue(null);
|
||||
await (f.adapter as any).dismissSkippableCodexUpdatePrompt(binding.tmuxSession, {handled:false}, 1);
|
||||
expect(f.commands).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["pane", "group", "process", "command", "menu"])("rechecks %s identity before choosing", async (changed) => {
|
||||
const f=fixture({command:"node"});
|
||||
// Directly bound one observation isolates the before-send recheck;
|
||||
// later launch polling is covered separately above.
|
||||
if(changed==="pane") vi.mocked(f.tmux.getPanePid).mockResolvedValueOnce(101).mockResolvedValue(200);
|
||||
if(changed==="group") f.listProcesses.mockReturnValueOnce(processRows("wrapper")).mockReturnValue(processRows("wrapper").map(r=>({...r,tpgid:500})));
|
||||
if(changed==="process") f.listProcesses.mockReturnValueOnce(processRows("wrapper")).mockReturnValue(processRows("wrapper").map(r=>r.pid===103?{...r,pid:104}:r));
|
||||
if(changed==="command") vi.mocked(f.tmux.getPaneCommand).mockResolvedValueOnce("node").mockResolvedValue("zsh");
|
||||
if(changed==="menu") vi.mocked(f.tmux.capturePaneScreen).mockResolvedValueOnce(MENU).mockResolvedValue(READY);
|
||||
const attempt = {handled:false};
|
||||
await (f.adapter as any).dismissSkippableCodexUpdatePrompt(binding.tmuxSession,attempt,1);
|
||||
expect(attempt.handled).toBe(true);
|
||||
vi.mocked(f.tmux.getPanePid).mockResolvedValue(101);
|
||||
vi.mocked(f.tmux.getPaneCommand).mockResolvedValue("node");
|
||||
vi.mocked(f.tmux.capturePaneScreen).mockResolvedValue(MENU);
|
||||
f.listProcesses.mockReturnValue(processRows("wrapper"));
|
||||
await (f.adapter as any).dismissSkippableCodexUpdatePrompt(binding.tmuxSession,attempt,1);
|
||||
expect(f.commands).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows one choice on a separately requested new launch", async () => {
|
||||
const f = fixture();
|
||||
await f.adapter.launchHarness(binding, opts);
|
||||
f.reset();
|
||||
await f.adapter.launchHarness(binding, opts);
|
||||
expect(f.commands).toEqual(Array(2).fill("tmux send-keys -t 'checker@test' '3'"));
|
||||
expect(f.counts()).toEqual({ updates: 0, dismissals: 2 });
|
||||
expect(f.leaked).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("negative control: real bracketed Paste3 then Enter selects UpdateNow in the pinned model", async () => {
|
||||
const f = fixture();
|
||||
await f.tmux.sendText(binding.tmuxSession!, "3");
|
||||
await f.tmux.sendKeys(binding.tmuxSession!, ["Enter"]);
|
||||
expect(f.counts()).toEqual({ updates: 1, dismissals: 0 });
|
||||
expect(f.ready()).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { findExactNativeResumeProcess, verifyCodexPaneProcess, type NativeProcessRow } from "../src/domain/native-process-lineage.js";
|
||||
|
||||
const token = "00000000-0000-7000-8000-000000000001";
|
||||
const startedAt = "Sat Jan 1 12:00:00 2000";
|
||||
function rows(): NativeProcessRow[] {
|
||||
return [
|
||||
{ pid: 10, ppid: 1, pgid: 10, tpgid: 11, executableName: "zsh", command: "-zsh", startedAt },
|
||||
{ pid: 11, ppid: 10, pgid: 11, tpgid: 11, executableName: "bash", command: "/bin/sh /tmp/openrig-tmux-send.txt", startedAt },
|
||||
{ pid: 12, ppid: 11, pgid: 11, tpgid: 11, executableName: "node", command: `node /opt/bin/codex resume ${token}`, startedAt },
|
||||
{ pid: 13, ppid: 12, pgid: 11, tpgid: 11, executableName: "codex", command: `/opt/native/codex -p resume resume --add-dir /tmp/state ${token}`, startedAt },
|
||||
];
|
||||
}
|
||||
const check = (listProcesses: () => NativeProcessRow[] | Promise<NativeProcessRow[]>, overrides = {}) => verifyCodexPaneProcess({
|
||||
target: "%1", tmux: { getPanePid: async () => 10 }, listProcesses, expectedToken: token, requireResume: true, ...overrides,
|
||||
});
|
||||
|
||||
describe("joined native Codex identity", () => {
|
||||
it("selects the unique native process, not its Node wrapper", async () => {
|
||||
expect((await check(rows))?.process.pid).toBe(13);
|
||||
expect(findExactNativeResumeProcess(rows(), 10, "codex", token)?.pid).toBe(13);
|
||||
});
|
||||
it("proves direct-native resume", async () => {
|
||||
expect((await check(() => [{ ...rows()[3]!, pid: 10, ppid: 1 }]))?.process.pid).toBe(10);
|
||||
});
|
||||
it("distinguishes fresh/non-strict runtime proof from exact resume", async () => {
|
||||
const fresh = () => rows().map(r => r.pid === 13 ? { ...r, command: "/opt/native/codex -m model" } : r);
|
||||
expect(await check(fresh)).toBeNull();
|
||||
expect(await check(fresh, { requireResume: false })).not.toBeNull();
|
||||
expect(await check(rows, { requireResume: false, expectedToken: "different" })).toBeNull();
|
||||
expect(await check(rows, { requireResume: true, expectedToken: null })).toBeNull();
|
||||
expect(await check(rows, { requireResume: false, expectedToken: null })).toBeNull();
|
||||
expect(await check(() => rows().map(r => r.pid === 13 ? { ...r, command: "/opt/native/codex resume --last" } : r), { requireResume: false, expectedToken: null })).toBeNull();
|
||||
});
|
||||
const controls: [string, (r: NativeProcessRow[]) => NativeProcessRow[]][] = [
|
||||
["wrong UUID", r => r.map(x => x.pid === 13 ? { ...x, command: "/opt/native/codex resume other" } : x)],
|
||||
["missing UUID", r => r.map(x => x.pid === 13 ? { ...x, command: "/opt/native/codex resume" } : x)],
|
||||
["token only in prompt", r => r.map(x => x.pid === 13 ? { ...x, command: `/opt/native/codex resume other ${token}` } : x)],
|
||||
["wrong OS executable", r => r.map(x => x.pid === 13 ? { ...x, executableName: "printf" } : x)],
|
||||
["argv-only executable", r => r.map(x => x.pid === 13 ? { ...x, command: `/bin/echo codex resume ${token}` } : x)],
|
||||
["unrelated descendant", r => r.map(x => x.pid === 13 ? { ...x, ppid: 999 } : x)],
|
||||
["background native", r => r.map(x => x.pid === 13 ? { ...x, pgid: 99 } : x)],
|
||||
["conflicting foreground", r => r.map(x => x.pid === 13 ? { ...x, tpgid: 99 } : x)],
|
||||
["missing root", r => r.slice(1)],
|
||||
["missing ancestry", r => r.filter(x => x.pid !== 11)],
|
||||
["cyclic ancestry", r => r.map(x => x.pid === 11 ? { ...x, ppid: 13 } : x)],
|
||||
["multiple native candidates", r => [...r, { ...r[3]!, pid: 14 }]],
|
||||
["duplicate PID", r => [...r, r[3]!]],
|
||||
["missing start time", r => r.map(x => ({ ...x, startedAt: undefined }))],
|
||||
["missing group", r => r.map(x => ({ ...x, tpgid: undefined }))],
|
||||
["exited native", r => r.filter(x => x.pid !== 13)],
|
||||
];
|
||||
it.each(controls)("refuses %s", async (_name, mutate) => {
|
||||
expect(await check(() => mutate(rows()))).toBeNull();
|
||||
});
|
||||
it.each(["startedAt", "command", "ppid", "pgid"] as const)("refuses a changed native %s between observations", async (field) => {
|
||||
const changed = rows().map(r => r.pid === 13 ? { ...r, [field]: field === "startedAt" ? "Sat Jan 1 12:00:01 2000" : field === "command" ? r.command + " --verbose" : 99 } : r);
|
||||
expect(await check(vi.fn().mockResolvedValueOnce(rows()).mockResolvedValueOnce(changed))).toBeNull();
|
||||
});
|
||||
it("refuses a reused pane PID even when the native PID is unchanged", async () => {
|
||||
const changed = rows().map(r => r.pid === 10 ? { ...r, startedAt: "Sat Jan 1 12:00:01 2000" } : r);
|
||||
expect(await check(vi.fn().mockResolvedValueOnce(rows()).mockResolvedValueOnce(changed))).toBeNull();
|
||||
});
|
||||
it("refuses a changed or missing pane and process observation failures", async () => {
|
||||
expect(await check(rows, { tmux: { getPanePid: vi.fn().mockResolvedValueOnce(10).mockResolvedValueOnce(11) } })).toBeNull();
|
||||
expect(await check(rows, { tmux: { getPanePid: async () => null } })).toBeNull();
|
||||
expect(await check(async () => { throw new Error("ps failed"); })).toBeNull();
|
||||
});
|
||||
it("retains the existing Claude exact-token contract", () => {
|
||||
expect(findExactNativeResumeProcess([{ pid: 10, ppid: 1, command: `claude --resume ${token}` }], 10, "claude-code", token)?.pid).toBe(10);
|
||||
expect(findExactNativeResumeProcess([{ pid: 10, ppid: 1, command: "claude --resume wrong" }], 10, "claude-code", token)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1262,6 +1262,8 @@ state: 2-named
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok && result.code === "attention_required") {
|
||||
expect(result.message).toMatch(/inspect/i);
|
||||
expect(result.message).not.toMatch(/approve and resume|NOT failed/);
|
||||
expect(result.rigId).toBeDefined();
|
||||
expect(result.attentionNodes).toBeInstanceOf(Array);
|
||||
expect(result.attentionNodes!.length).toBe(1);
|
||||
|
||||
@@ -33,6 +33,7 @@ import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { AppliedLaunchObservationStore } from "../src/domain/applied-launch-observation-store.js";
|
||||
import { observeClaudePermission, observeCodexSandbox, observePiResourceTrust, type AppliedLaunchObservation } from "../src/domain/permission-drift.js";
|
||||
import { buildCodexResumeCore } from "../src/domain/native-resume-probe.js";
|
||||
import { SeatIdentityReconciler } from "../src/domain/seat-identity-reconciler.js";
|
||||
|
||||
function setupDb(): Database.Database {
|
||||
return createFullTestDb();
|
||||
@@ -884,7 +885,41 @@ describe("RestoreOrchestrator", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a managed Codex node wrapper resumed when its child lineage carries the saved native session", async () => {
|
||||
it.each(["full", "subset", "existing-hook", "conflicting-hook"])("proves synthetic shell-wrapped Codex through %s recovery and the next identity poll", async (mode) => {
|
||||
const token = "00000000-0000-7000-8000-000000000001";
|
||||
const snap = seedRigAndSnapshot({ nodes: [{ logicalId: "worker", role: "worker", runtime: "codex" }], edges: [], resumeType: "codex_id", resumeToken: token });
|
||||
// Synthetic IDs, paths and times preserve shell/Node/native foreground relationships.
|
||||
const rows = [
|
||||
{ pid: 3001, ppid: 3000, pgid: 3001, tpgid: 3002, executableName: "zsh", command: "-zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 3002, ppid: 3001, pgid: 3002, tpgid: 3002, executableName: "bash", command: "/bin/sh /tmp/openrig-tmux-send.txt", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 3003, ppid: 3002, pgid: 3002, tpgid: 3002, executableName: "node", command: `node /opt/bin/codex -s workspace-write -m fixture-model resume --add-dir /tmp/state ${token}`, startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 3004, ppid: 3003, pgid: 3002, tpgid: 3002, executableName: "codex", command: `/opt/native/codex -s workspace-write -m fixture-model resume --add-dir /tmp/state ${token}`, startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
];
|
||||
const tmux = { ...mockTmux(), getPanePid: vi.fn(async () => 3001), getPaneCommand: vi.fn(async () => "bash") } as unknown as TmuxAdapter;
|
||||
const listProcesses = async () => {
|
||||
if (mode.endsWith("hook")) db.prepare("UPDATE sessions SET resume_token = ?, resume_provenance = 'hook' WHERE id = (SELECT id FROM sessions ORDER BY id DESC LIMIT 1)").run(mode === "existing-hook" ? token : "other-thread");
|
||||
return rows;
|
||||
};
|
||||
const orch = createOrchestrator({ tmux, listProcesses });
|
||||
const result = mode !== "subset" ? await orch.restore(snap.id) : await orch.launchSingleNode(snap.rigId, "worker", { snapshotId: snap.id });
|
||||
expect(result.ok).toBe(true);
|
||||
const nodes = "result" in result ? result.result?.nodes : result.launched;
|
||||
if (mode === "conflicting-hook") {
|
||||
expect(nodes?.[0]?.status).toBe("attention_required");
|
||||
expect(db.prepare("SELECT verdict FROM seat_identity_verdicts WHERE node_id = ?").get(nodes![0]!.nodeId)).toEqual({ verdict: "mismatch" });
|
||||
expect(db.prepare("SELECT resume_token, resume_provenance FROM sessions ORDER BY id DESC LIMIT 1").get()).toEqual({ resume_token: "other-thread", resume_provenance: "hook" });
|
||||
return;
|
||||
}
|
||||
expect(nodes?.[0]?.status).toBe("resumed");
|
||||
if (mode === "existing-hook") expect(db.prepare("SELECT resume_provenance FROM sessions ORDER BY id DESC LIMIT 1").get()).toEqual({ resume_provenance: "hook" });
|
||||
const nodeId = nodes![0]!.nodeId;
|
||||
const name = sessionRegistry.getBindingForNode(nodeId)!.tmuxSession!;
|
||||
tmux.listSessions = vi.fn(async () => [{ name }] as never);
|
||||
await new SeatIdentityReconciler({ db, tmux, listProcesses } as ConstructorParameters<typeof SeatIdentityReconciler>[0]).reconcileAll();
|
||||
expect(db.prepare("SELECT verdict, observed_pid FROM seat_identity_verdicts WHERE node_id = ?").get(nodeId)).toEqual({ verdict: "verified", observed_pid: 3004 });
|
||||
});
|
||||
|
||||
it("reports a managed Codex wrapper resumed when its native child carries the saved session", async () => {
|
||||
const resumeToken = "01a05645-37a2-7dd0-970c-031d2f2510cb";
|
||||
const snap = seedRigAndSnapshot({
|
||||
nodes: [{ logicalId: "worker", role: "worker", runtime: "codex" }],
|
||||
@@ -893,13 +928,13 @@ describe("RestoreOrchestrator", () => {
|
||||
resumeToken,
|
||||
});
|
||||
const tmux = { ...mockTmux(), getPaneCommand: vi.fn(async () => "node") } as unknown as TmuxAdapter;
|
||||
const codexCommand = `node /opt/openrig/lib/node_modules/@openai/codex/bin/codex --model gpt-5.6 resume --add-dir /tmp/openrig-state ${resumeToken}`;
|
||||
const codexCommand = `/opt/openrig/vendor/bin/codex --model gpt-5.6 resume --add-dir /tmp/openrig-state ${resumeToken}`;
|
||||
const result = await createOrchestrator({
|
||||
tmux,
|
||||
codex: mockCodexResume({ ok: true }),
|
||||
listProcesses: async () => [
|
||||
{ pid: 1234, ppid: 1, command: "-zsh" },
|
||||
{ pid: 54776, ppid: 1234, command: codexCommand },
|
||||
{ pid: 1234, ppid: 1, command: "-zsh", pgid: 1234, tpgid: 54776, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 54776, ppid: 1234, command: codexCommand, pgid: 54776, tpgid: 54776, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
],
|
||||
}).restore(snap.id);
|
||||
|
||||
@@ -929,8 +964,8 @@ describe("RestoreOrchestrator", () => {
|
||||
tmux,
|
||||
codex: mockCodexResume({ ok: true }),
|
||||
listProcesses: async () => [
|
||||
{ pid: 1234, ppid: 1, command: "-zsh" },
|
||||
{ pid: 54776, ppid: 1234, command: codexCommand },
|
||||
{ pid: 1234, ppid: 1, command: "-zsh", pgid: 1234, tpgid: 54776, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 54776, ppid: 1234, command: codexCommand, pgid: 54776, tpgid: 54776, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
],
|
||||
}).restore(snap.id);
|
||||
|
||||
@@ -960,8 +995,8 @@ describe("RestoreOrchestrator", () => {
|
||||
tmux,
|
||||
codex: mockCodexResume({ ok: true }),
|
||||
listProcesses: async () => [
|
||||
{ pid: 1234, ppid: 1, command: "-zsh" },
|
||||
{ pid: 54776, ppid: 1234, command: codexCommand },
|
||||
{ pid: 1234, ppid: 1, command: "-zsh", pgid: 1234, tpgid: 54776, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 54776, ppid: 1234, command: codexCommand, pgid: 54776, tpgid: 54776, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
],
|
||||
}).restore(snap.id);
|
||||
|
||||
@@ -2698,9 +2733,8 @@ describe("RestoreOrchestrator", () => {
|
||||
withResumeToken?: boolean;
|
||||
withBinding?: boolean;
|
||||
}) {
|
||||
// Session name validator requires legacy `r{NN}-{suffix}` or canonical
|
||||
// `{pod}-{member}@{rig}`. Each call gets a unique numeric rig id so the
|
||||
// legacy pattern matches and rig names don't collide.
|
||||
// Use canonical names so adding cases cannot overflow the legacy
|
||||
// two-digit rig-name range.
|
||||
const rigName = opts.rigName ?? `r${nextSeed++}`;
|
||||
const logicalId = opts.logicalId ?? "worker";
|
||||
const runtime = opts.runtime ?? "claude-code";
|
||||
@@ -2708,7 +2742,7 @@ describe("RestoreOrchestrator", () => {
|
||||
const node = rigRepo.addNode(rig.id, logicalId, { role: "worker", runtime });
|
||||
|
||||
// Bind a tmux session name so the reconciler can probe.
|
||||
const sessionName = `${rigName}-${logicalId}`;
|
||||
const sessionName = `test-${logicalId}@${rigName}`;
|
||||
if (opts.withBinding ?? true) {
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: sessionName });
|
||||
}
|
||||
@@ -2740,6 +2774,30 @@ describe("RestoreOrchestrator", () => {
|
||||
return { rig, nodeId: node.id, sessionName };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["OpenAI Codex (v0.155.1)\n› continue", true],
|
||||
["Update available!", false],
|
||||
["Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.", false],
|
||||
["Do you trust the contents of this directory?", false],
|
||||
])("reconciles wrapped Codex without input only with usable content: %s", async (content, usable) => {
|
||||
const tmux = mockTmuxForReconciler();
|
||||
vi.mocked(tmux.hasSession).mockResolvedValue(true);
|
||||
vi.mocked(tmux.getPaneCommand).mockResolvedValue("bash");
|
||||
vi.mocked(tmux.capturePaneContent).mockResolvedValue(String(content));
|
||||
const seeded = seedFailedAttempt({ runtime: "codex", restoreOutcome: "attention_required", withResumeToken: true });
|
||||
const oldEvent = db.prepare("SELECT payload FROM events WHERE type = 'restore.completed'").get();
|
||||
const listProcesses = async () => [
|
||||
{ pid: 1234, ppid: 1, pgid: 1234, tpgid: 1235, command: "bash", executableName: "bash", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: 1235, ppid: 1234, pgid: 1235, tpgid: 1235, command: "codex resume tok-abc-123", executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
];
|
||||
const result = await createOrchestrator({ tmux, listProcesses }).reconcileNodeRuntimeTruth(seeded.rig.id, seeded.nodeId);
|
||||
expect(result.ok).toBe(usable);
|
||||
expect(tmux.sendKeys).not.toHaveBeenCalled();
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
expect(db.prepare("SELECT payload FROM events WHERE type = 'restore.completed'").get()).toEqual(oldEvent);
|
||||
expect(db.prepare("SELECT * FROM events WHERE type = 'restore.outcome_reconciled'").all()).toHaveLength(usable ? 1 : 0);
|
||||
});
|
||||
|
||||
it("upgrades failed -> operator_recovered when ALL four preconditions hold; emits audit event", async () => {
|
||||
const tmux = mockTmuxForReconciler();
|
||||
(tmux.hasSession as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
||||
|
||||
@@ -120,6 +120,8 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
sessionRegistry,
|
||||
eventBus,
|
||||
tmuxAdapter: tmux,
|
||||
listProcesses: async () => [{ pid: 4242, ppid: 1, pgid: 4242, tpgid: 4242,
|
||||
executableName: "codex", command: "/opt/native/codex -m model", startedAt: "Sat Jan 1 12:00:00 2000" }],
|
||||
nodeLauncher,
|
||||
startupOrchestrator,
|
||||
runtimeAdapters: { "claude-code": adapter, codex: { ...adapter, runtime: "codex" } },
|
||||
|
||||
@@ -105,6 +105,53 @@ describe("classifyPaneRuntimeMatch", () => {
|
||||
});
|
||||
|
||||
describe("SeatIdentityReconciler.reconcileAll", () => {
|
||||
it("shares two native observations across wrapped Codex seats and rejects later stale or missing proof", async () => {
|
||||
const db = createFullTestDb();
|
||||
for (const n of [1, 2]) {
|
||||
seedSeat(db, { nodeId: `c${n}`, sessionName: `c${n}@rig`, pane: `%${n}`, runtime: "codex" });
|
||||
db.prepare("UPDATE sessions SET resume_token = ? WHERE node_id = ?").run(`thread-${n}`, `c${n}`);
|
||||
}
|
||||
const tmux = makeTmux({ sessions: ["c1@rig", "c2@rig"], panePid: { "%1": 10, "%2": 20 }, paneCommand: { "%1": "bash", "%2": "node" } });
|
||||
const rows = [1, 2].flatMap(n => [
|
||||
{ pid: n * 10, ppid: 1, pgid: n * 10, tpgid: n * 10 + 1, executableName: "bash", command: "bash", startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
{ pid: n * 10 + 1, ppid: n * 10, pgid: n * 10 + 1, tpgid: n * 10 + 1, executableName: "codex", command: `codex resume thread-${n}`, startedAt: "Sat Jan 1 12:00:00 2000" },
|
||||
]);
|
||||
const listProcesses = vi.fn(async () => rows);
|
||||
const rec = new SeatIdentityReconciler({ db, tmux, listProcesses, now: NOW });
|
||||
const store = new SeatIdentityStore(db);
|
||||
await rec.reconcileAll();
|
||||
expect(listProcesses).toHaveBeenCalledTimes(2);
|
||||
for (const n of [1, 2]) expect(store.getForNode(`c${n}`)).toMatchObject({ verdict: "verified", evidence: { observedPid: n * 10 + 1 } });
|
||||
|
||||
// A reused native PID during the next sweep cannot retain the old green.
|
||||
listProcesses.mockResolvedValueOnce(rows).mockResolvedValueOnce(rows.map(r => r.pid === 11 ? { ...r, startedAt: "Sat Jan 1 12:00:01 2000" } : r));
|
||||
await rec.reconcileAll();
|
||||
expect(store.getForNode("c1")?.verdict).toBe("mismatch");
|
||||
expect(store.getForNode("c2")?.verdict).toBe("verified");
|
||||
|
||||
db.prepare("UPDATE sessions SET resume_token = 'wrong' WHERE node_id = 'c1'").run();
|
||||
await rec.reconcileAll();
|
||||
expect(store.getForNode("c1")?.verdict).toBe("mismatch");
|
||||
listProcesses.mockResolvedValue([]);
|
||||
await rec.reconcileAll();
|
||||
for (const n of [1, 2]) expect(store.getForNode(`c${n}`)?.verdict).toBe("mismatch");
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("keeps fresh Codex distinct from resume, and cannot verify an unavailable pane", async () => {
|
||||
const db = createFullTestDb();
|
||||
seedSeat(db, { nodeId: "c1", sessionName: "c1@rig", pane: "%1", runtime: "codex" });
|
||||
const tmux = makeTmux({ sessions: ["c1@rig"], panePid: { "%1": 10 }, paneCommand: { "%1": "codex" } });
|
||||
const listProcesses = vi.fn(async () => [{ pid: 10, ppid: 1, pgid: 10, tpgid: 10, executableName: "codex", command: "codex -m configured-model", startedAt: "Sat Jan 1 12:00:00 2000" }]);
|
||||
const rec = new SeatIdentityReconciler({ db, tmux, listProcesses, now: NOW });
|
||||
await rec.reconcileAll();
|
||||
expect(new SeatIdentityStore(db).getForNode("c1")?.verdict).toBe("verified");
|
||||
vi.mocked(tmux.listSessions).mockRejectedValue(new Error("unavailable"));
|
||||
await rec.reconcileAll();
|
||||
expect(new SeatIdentityStore(db).getForNode("c1")?.verdict).toBe("mismatch");
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("VERIFIED — matching pane pid + command persists a verified verdict", async () => {
|
||||
const db = createFullTestDb();
|
||||
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", pane: "%1", runtime: "claude-code" });
|
||||
|
||||
@@ -858,7 +858,7 @@ edges: []
|
||||
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!.error.action).toMatch(/^Inspect the affected session/);
|
||||
expect(r!.attentionNodes).toHaveLength(1);
|
||||
expect(r!.attentionNodes[0]!.logicalId).toBe("dev.impl");
|
||||
});
|
||||
@@ -885,7 +885,7 @@ edges: []
|
||||
const r = buildAttentionResponse(result);
|
||||
expect(r).not.toBeNull();
|
||||
// Plural phrasing
|
||||
expect(r!.error.action).toMatch(/^Attach to each parked session/);
|
||||
expect(r!.error.action).toMatch(/^Inspect each affected 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");
|
||||
@@ -915,7 +915,7 @@ edges: []
|
||||
};
|
||||
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).toMatch(/^Inspect each affected 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");
|
||||
|
||||
Reference in new issue
Block a user