mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b78aa6539d | ||
|
|
df7ee77433 | ||
|
|
e773e5b1f3 |
No files matched your search
+4
-122
@@ -1,26 +1,12 @@
|
||||
name: Tests
|
||||
|
||||
# Builds, type-checks and runs every test suite on a clean GitHub runner, and records the file
|
||||
# list each published package would contain. Each suite is its own job so one failure does not
|
||||
# hide the others. No provider credentials or operator configuration are supplied.
|
||||
# Package suites deny external networking; scenario runtime has no external route.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-package:
|
||||
f4-compare:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -31,109 +17,5 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- name: Record the published file list of each package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p pack-lists
|
||||
for p in cli daemon tui; do
|
||||
(cd packages/$p && npm pack --dry-run --json --ignore-scripts) \
|
||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{for(const f of JSON.parse(s)[0].files)console.log(f.path)})' \
|
||||
| sort > pack-lists/$p.txt
|
||||
echo "$p: $(wc -l < pack-lists/$p.txt) files"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pack-lists
|
||||
path: pack-lists/
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
|
||||
repo-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:repo
|
||||
|
||||
package-tests:
|
||||
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
|
||||
# file-inode behaviour and release tags.
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package: [daemon, cli, tui, ui]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: brew install tmux
|
||||
- run: npm ci
|
||||
- name: Build the packages the tests import
|
||||
run: npm run build
|
||||
- name: Run the suite without ambient credentials or external network
|
||||
run: |
|
||||
suite_root=$(mktemp -d "$RUNNER_TEMP/opr-test.XXXXXX")
|
||||
suite_root=$(cd "$suite_root" && pwd -P)
|
||||
mkdir -p "$suite_root/home" "$suite_root/bin"
|
||||
# Same system bytes, nonprivileged mode; no system file is changed.
|
||||
/usr/bin/install -m 0755 /bin/ps "$suite_root/bin/ps"
|
||||
# Only this job's temporary sockets (including tmux) are reachable.
|
||||
env -i HOME="$suite_root/home" PATH="$PATH" TMPDIR="$suite_root" TMUX_TMPDIR="$suite_root" \
|
||||
CI=true TERM=xterm-256color \
|
||||
sandbox-exec -D IPC_ROOT="$suite_root" -p '(version 1)(allow default)(deny network-outbound)(allow network-outbound (remote ip "localhost:*"))(allow network-outbound (subpath (param "IPC_ROOT")))' \
|
||||
/bin/bash -s "${{ matrix.package }}" <<'SUITE'
|
||||
set -euo pipefail
|
||||
node scripts/ci-macos-process-probe.mjs
|
||||
# The production readers use bare ps. Select real system bytes only after
|
||||
# self/owned-child observation and network/IPC confinement are proved.
|
||||
export PATH="$TMPDIR/bin:$PATH"
|
||||
test "$(command -v ps)" = "$TMPDIR/bin/ps"
|
||||
exec npm run test -w "packages/$1"
|
||||
SUITE
|
||||
|
||||
installed-scenario:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Installed package, daemon restart, and seeded durability failure
|
||||
run: bash scripts/run-pr-scenarios.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: installed-scenario-evidence
|
||||
path: dist/pr-scenarios/
|
||||
if-no-files-found: error
|
||||
- name: The F4 comparison only
|
||||
run: npm exec -w packages/daemon -- vitest run test/f4-1191-compare.test.ts --reporter=verbose
|
||||
@@ -39,11 +39,25 @@ export interface TmuxFileOps {
|
||||
bufferName(): string;
|
||||
}
|
||||
|
||||
function defaultTmuxFileOps(): TmuxFileOps {
|
||||
// One source for the staged script's name: the generator (defaultTmuxFileOps) and the matcher
|
||||
// (isStagedSendScriptPath) are both built from it.
|
||||
const SEND_SCRIPT_PREFIX = "openrig-tmux-send-";
|
||||
const SEND_SCRIPT_SUFFIX = ".txt";
|
||||
const SEND_SCRIPT_NAME = new RegExp(
|
||||
`^${SEND_SCRIPT_PREFIX}\\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}${SEND_SCRIPT_SUFFIX.replace(".", "\\.")}$`,
|
||||
);
|
||||
|
||||
/** Whether `path` names a launch script sendShellCommand staged (an absolute path with the name
|
||||
* defaultTmuxFileOps gives it). The script removes itself first, so this is a name check only. */
|
||||
export function isStagedSendScriptPath(path: string): boolean {
|
||||
return path.startsWith("/") && SEND_SCRIPT_NAME.test(path.split("/").pop() ?? "");
|
||||
}
|
||||
|
||||
export function defaultTmuxFileOps(): TmuxFileOps {
|
||||
return {
|
||||
writeFile: (p, content, options) => fsWriteFile(p, content, { encoding: "utf8", ...options }),
|
||||
unlink: (p) => fsUnlink(p),
|
||||
tmpName: () => pathJoin(tmpdir(), `openrig-tmux-send-${process.pid}-${randomUUID()}.txt`),
|
||||
tmpName: () => pathJoin(tmpdir(), `${SEND_SCRIPT_PREFIX}${process.pid}-${randomUUID()}${SEND_SCRIPT_SUFFIX}`),
|
||||
bufferName: () => `openrig_${process.pid}_${randomUUID().replace(/-/g, "")}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { isStagedSendScriptPath, type TmuxAdapter } from "../adapters/tmux.js";
|
||||
import type { ArbitratedSeatState } from "./activity-taxonomy.js";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
import type { SessionRegistry } from "./session-registry.js";
|
||||
@@ -395,9 +395,13 @@ export class CodexInPlaceRestart {
|
||||
const top = chain.at(-1)!;
|
||||
const root = rows.find((row) => row.pid === panePid);
|
||||
let paneShape: PaneShape;
|
||||
// OpenRig types a Codex launch (and this restart's relaunch) as `/bin/sh <staged script>`, so a managed
|
||||
// Codex runs one sh below the pane's shell. Only that script is accepted between them.
|
||||
const between = rows.find((row) => row.pid === top.ppid);
|
||||
const viaLaunchScript = !!between && between.ppid === panePid && isStagedLaunchScript(between);
|
||||
if (top.pid === panePid) paneShape = "direct_native";
|
||||
else if (root && top.ppid === panePid && isShellForeground(executableBase(root))) paneShape = "shell_backed";
|
||||
else return { ok: false, code: "pane_shape_unknown", message: "The pane is neither a shell running Codex nor Codex itself; nothing was stopped." };
|
||||
else if (root && (top.ppid === panePid || viaLaunchScript) && isShellForeground(executableBase(root))) paneShape = "shell_backed";
|
||||
else return { ok: false, code: "pane_shape_unknown", message: "The pane is neither a shell running Codex (directly or through OpenRig's own launch script) nor Codex itself; nothing was stopped." };
|
||||
|
||||
// Where the relaunched Codex would run, and with which home.
|
||||
let cwd: string;
|
||||
@@ -727,6 +731,16 @@ function executableBase(row: NativeProcessRow): string {
|
||||
return (name.split("/").pop() ?? name).replace(/^-/, "");
|
||||
}
|
||||
|
||||
/** The shells `/bin/sh` can run as. macOS's /bin/sh is a shim that runs bash (or dash or zsh, if so
|
||||
* configured), so the OS names the staged script's process after that shell, not sh. */
|
||||
const SH_RUNS_AS = new Set(["sh", "bash", "dash", "zsh"]);
|
||||
|
||||
/** `/bin/sh <path>` running a launch script that OpenRig's tmux adapter staged, and nothing else. */
|
||||
function isStagedLaunchScript(row: NativeProcessRow): boolean {
|
||||
const argv = processArgv(row.command);
|
||||
return SH_RUNS_AS.has(executableBase(row)) && argv.length === 2 && /^(?:\/bin\/)?sh$/.test(argv[0]!) && isStagedSendScriptPath(argv[1]!);
|
||||
}
|
||||
|
||||
/** The Codex process and its Codex launcher ancestors, deepest first, up to the pane root. */
|
||||
function codexChain(rows: NativeProcessRow[], deepest: NativeProcessRow, panePid: number): NativeProcessRow[] {
|
||||
const byPid = new Map(rows.map((row) => [row.pid, row]));
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Desk-only comparison (never merged): one pod-aware Claude resume through the real adapter ->
|
||||
// startup -> restore mapping, with mocked terminal effects, at v0.6.10 and at the 0.6.11 candidate.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { SnapshotRepository } from "../src/domain/snapshot-repository.js";
|
||||
import { CheckpointStore } from "../src/domain/checkpoint-store.js";
|
||||
import { SnapshotCapture } from "../src/domain/snapshot-capture.js";
|
||||
import { RigTeardownOrchestrator } from "../src/domain/rig-teardown.js";
|
||||
import { NodeLauncher } from "../src/domain/node-launcher.js";
|
||||
import { RestoreOrchestrator } from "../src/domain/restore-orchestrator.js";
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
|
||||
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
|
||||
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
|
||||
import type { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
|
||||
const token = "00000000-0000-4000-8000-000000000006";
|
||||
const autoScreen = "Restored conversation\n────────────────\n❯ \n────────────────\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents\n ● high · /effort\n";
|
||||
const SCREENS: Record<string, string> = {
|
||||
missing: `$ claude --resume ${token}\nNo conversation found with session ID: ${token}\nuser@host:~/project$ `,
|
||||
"generic-exit": "$ claude --resume <token>\nError: simulated Claude exit (not a missing conversation)\nuser@host:~/project$ ",
|
||||
"attention-control": autoScreen,
|
||||
};
|
||||
|
||||
describe("F4 #1191 comparison", () => {
|
||||
const dbs: ReturnType<typeof createFullTestDb>[] = [];
|
||||
afterEach(() => { for (const db of dbs.splice(0)) db.close(); });
|
||||
|
||||
it.each(["missing", "generic-exit", "attention-control"])("%s", async (mode) => {
|
||||
const db = createFullTestDb(); dbs.push(db);
|
||||
const rigRepo = new RigRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
const eventBus = new EventBus(db);
|
||||
const snapshotRepo = new SnapshotRepository(db);
|
||||
const checkpointStore = new CheckpointStore(db);
|
||||
const snapshotCapture = new SnapshotCapture({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, checkpointStore });
|
||||
const rig = rigRepo.createRig("restore-test");
|
||||
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("restore-pod", rig.id, "Test");
|
||||
const node = rigRepo.addNode(rig.id, "test.c", { runtime: "claude-code", podId: "restore-pod" });
|
||||
new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "auto" }, "fixture", "retained posture");
|
||||
const name = "test-c@restore-test";
|
||||
const old = sessionRegistry.registerSession(node.id, name);
|
||||
sessionRegistry.updateStatus(old.id, "running");
|
||||
sessionRegistry.updateResumeToken(old.id, "claude_id", token, "scrape");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%old" });
|
||||
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "claude-code");
|
||||
let live = true;
|
||||
const tmux = {
|
||||
hasSession: vi.fn(async () => live),
|
||||
probeSession: vi.fn(async () => ({ state: live ? "present" : "absent" })),
|
||||
killSession: vi.fn(async () => { live = false; return { ok: true }; }),
|
||||
createSession: vi.fn(async () => { live = true; return { ok: true }; }),
|
||||
listSessions: vi.fn(async () => live ? [{ name }] : []),
|
||||
listWindows: vi.fn(async () => []),
|
||||
listPanes: vi.fn(async () => [{ id: "%new", index: 0, cwd: "/", width: 80, height: 24, active: true }]),
|
||||
getPanePid: vi.fn(async () => 100),
|
||||
getPaneCommand: vi.fn(async () => "bash"),
|
||||
capturePaneContent: vi.fn(async () => SCREENS[mode]),
|
||||
sendText: vi.fn(async () => ({ ok: true })),
|
||||
sendShellCommand: vi.fn(async () => ({ ok: true })),
|
||||
sendKeys: vi.fn(async () => ({ ok: true })),
|
||||
} as unknown as TmuxAdapter;
|
||||
const startedAt = "Thu Oct 1 05:53:16 2026";
|
||||
const listProcesses = async () => [{ pid: 100, ppid: 1, pgid: 100, tpgid: 100, executableName: "bash", command: "-bash", startedAt }];
|
||||
const down = await new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotCapture, tmuxAdapter: tmux }).teardown(rig.id);
|
||||
expect(down.errors).toEqual([]);
|
||||
const adapter = new ClaudeCodeAdapter({ tmux, listProcesses, sleep: async () => {},
|
||||
claudeManagedLaunch: { prepare: async () => ({ command: (args: readonly string[]) => `claude ${args.join(" ")}`, assertCurrent: () => {}, configDir: "/fixture", executable: "/opt/claude.exe" }) } as unknown as ClaudeManagedLaunch,
|
||||
fsOps: { exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {}, copyFile: () => {} } });
|
||||
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
|
||||
const restore = new RestoreOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, snapshotCapture,
|
||||
checkpointStore, nodeLauncher, tmuxAdapter: tmux, claudeResume: new ClaudeResumeAdapter(tmux),
|
||||
codexResume: new CodexResumeAdapter(tmux), listProcesses });
|
||||
vi.mocked(tmux.killSession).mockClear(); vi.mocked(tmux.createSession).mockClear();
|
||||
const up = await restore.restore(down.snapshotId!, { adapters: { "claude-code": adapter } });
|
||||
const rows = db.prepare("SELECT id, status, startup_status, resume_type, resume_token FROM sessions WHERE node_id = ? ORDER BY id").all(node.id);
|
||||
const report = {
|
||||
mode,
|
||||
ok: up.ok,
|
||||
rigResult: up.ok ? (up.result as { rigResult?: unknown }).rigResult ?? null : (up as { message?: string }).message,
|
||||
node: up.ok ? up.result.nodes[0] : null,
|
||||
createSessionCalls: vi.mocked(tmux.createSession).mock.calls.length,
|
||||
killSessionCalls: vi.mocked(tmux.killSession).mock.calls.map((c) => c[0]),
|
||||
sessionLiveAfter: live,
|
||||
oldRow: db.prepare("SELECT status, resume_type, resume_token FROM sessions WHERE id = ?").get(old.id),
|
||||
rows,
|
||||
binding: sessionRegistry.getBindingForNode(node.id),
|
||||
};
|
||||
console.log("F4_REPORT " + JSON.stringify(report));
|
||||
expect(up.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -93,7 +93,9 @@ describe.runIf(available())("in-place restart, native (private tmux socket, stub
|
||||
/** Each word single-quoted for the harness's sh, so tmux receives them as separate argv words. */
|
||||
const argv = (...words: string[]) => words.map(q).join(" ");
|
||||
|
||||
async function setup(shape: "shell_backed" | "direct_native", opts: { running?: { networkOverride: boolean; daemonOptOut: boolean; addDirs: string[] }; quiet?: boolean; prelude?: string } = {}) {
|
||||
async function setup(shape: "shell_backed" | "direct_native", opts: { running?: { networkOverride: boolean; daemonOptOut: boolean; addDirs: string[] }; quiet?: boolean; prelude?: string;
|
||||
/** Shell-backed: type the launch the way OpenRig does (tmux adapter's staged `/bin/sh <script>`). */
|
||||
staged?: boolean } = {}) {
|
||||
// One seat at a time: end the previous test's session and wait for its stub to exit.
|
||||
for (const name of sessions) await tmux(`kill-session -t ${q(name)}`).catch(() => {});
|
||||
for (let i = 0; i < 50 && stubPids().length > 0; i++) await sleep(100);
|
||||
@@ -141,8 +143,14 @@ describe.runIf(available())("in-place restart, native (private tmux socket, stub
|
||||
if (shape === "shell_backed") {
|
||||
await tmux(`-f /dev/null new-session -d -s ${q(seat)} -x 120 -y 30 -c ${q(work)} ${env} ${argv(...cleanStart)}`);
|
||||
await sleep(200);
|
||||
await tmux(`send-keys -t ${q(seat)} -l -- ${q(`${opts.prelude ?? ""}${preflight} && ${built.command} || echo PREFLIGHT-REFUSED`)}`);
|
||||
await tmux(`send-keys -t ${q(seat)} Enter`);
|
||||
const launch = `${opts.prelude ?? ""}${preflight} && ${built.command} || echo PREFLIGHT-REFUSED`;
|
||||
if (opts.staged) {
|
||||
const sent = await realTmux.sendShellCommand(seat, launch);
|
||||
if (!sent.ok) throw new Error(sent.message);
|
||||
} else {
|
||||
await tmux(`send-keys -t ${q(seat)} -l -- ${q(launch)}`);
|
||||
await tmux(`send-keys -t ${q(seat)} Enter`);
|
||||
}
|
||||
} else {
|
||||
const script = `${preflight} || { echo PREFLIGHT-REFUSED; exec sleep 30; }; exec ${built.command}`;
|
||||
await tmux(`-f /dev/null new-session -d -s ${q(seat)} -x 120 -y 30 -c ${q(work)} ${env} ${argv(...cleanStart, "-c", script)}`);
|
||||
@@ -195,6 +203,28 @@ describe.runIf(available())("in-place restart, native (private tmux socket, stub
|
||||
expect(verified.body).toMatchObject({ ok: true, outcome: "restarted_account_unverified", accountEvidence: { state: "not_requested" } });
|
||||
}, 60_000);
|
||||
|
||||
it("a Codex launched the way OpenRig launches it (staged sh script), restarted twice in the same pane", async () => {
|
||||
const { pane, post } = await setup("shell_backed", { staged: true });
|
||||
const shell = await panePid(pane);
|
||||
const [oldStub] = stubPids();
|
||||
const parentOf = (pid: number) => Number(fs.readFileSync(`/proc/${pid}/stat`, "utf8").replace(/^.*\) /, "").split(" ")[1]);
|
||||
const script = parentOf(oldStub!);
|
||||
expect(script, "a staged script sits between the shell and Codex").not.toBe(shell);
|
||||
expect(parentOf(script)).toBe(shell);
|
||||
expect(fs.readFileSync(`/proc/${script}/cmdline`, "utf8")).toMatch(/openrig-tmux-send-\d+-[0-9a-f-]+\.txt/);
|
||||
|
||||
for (const round of [1, 2]) {
|
||||
const plan = await post("restart-plan");
|
||||
expect(plan.body, `round ${round}: ${JSON.stringify(plan.body)}`).toMatchObject({ ok: true, paneShape: "shell_backed" });
|
||||
const result = await post("restart", { reason: `native harness staged round ${round}` });
|
||||
expect(result.status, JSON.stringify(result.body)).toBe(200);
|
||||
expect(result.body).toMatchObject({ outcome: "restarted_account_unverified", pane, paneShape: "shell_backed" });
|
||||
for (const pid of stubPids()) pids.add(pid);
|
||||
expect(stubPids(), `round ${round}: only the replacement stub runs`).toEqual([result.body.newProcess.pid]);
|
||||
expect(await panePid(pane), "same pane shell").toBe(shell);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
it("direct-native: respawned without -k, remain-on-exit put back after the verified replacement", async () => {
|
||||
const { seat, pane, post } = await setup("direct_native");
|
||||
const before = await panePid(pane);
|
||||
@@ -206,11 +236,16 @@ describe.runIf(available())("in-place restart, native (private tmux socket, stub
|
||||
for (const pid of stubPids()) pids.add(pid);
|
||||
const after = await panePid(pane);
|
||||
expect(after).not.toBe(before);
|
||||
expect(stubPids(), "only the replacement stub runs").toEqual([after]);
|
||||
// The replacement is the respawned pane's root, or its child where the image's sh doesn't exec a lone
|
||||
// command (review-r2's dash): compare with the reported process, and tie it to the pane separately.
|
||||
const replacement = result.body.newProcess.pid as number;
|
||||
expect(stubPids(), "only the replacement stub runs").toEqual([replacement]);
|
||||
const parent = Number(fs.readFileSync(`/proc/${replacement}/stat`, "utf8").replace(/^.*\) /, "").split(" ")[1]);
|
||||
expect([replacement, parent], "the replacement is the respawned pane's root or its direct child").toContain(after);
|
||||
// tmux resets the visible screen on respawn; the original's earlier output is kept in scrollback.
|
||||
const screen = await tmux(`capture-pane -p -t ${q(pane)} -S -300`);
|
||||
expect(screen, "the original's scrollback stays in the pane").toContain(`from pid ${before}`);
|
||||
expect(screen).toContain(`stub conversation ${TOKEN} pid ${after}`);
|
||||
expect(screen).toContain(`stub conversation ${TOKEN} pid ${replacement}`);
|
||||
expect((await tmux(`show-options -p -v -t ${q(pane)} remain-on-exit`)).trim(), "no pane-local setting left behind").toBe("");
|
||||
expect((await tmux(`list-panes -t ${q(seat)} -F '#{pane_id}'`)).trim()).toBe(pane);
|
||||
}, 60_000);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// OPR.0.7.0.5 — in-place restart of one Codex seat: every gate and every outcome, driven through the
|
||||
// public SeatLifecycleService surface against a fake tmux, process table, Codex launcher and OS reads.
|
||||
import { defaultTmuxFileOps, isStagedSendScriptPath } from "../src/adapters/tmux.js";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
@@ -26,6 +27,11 @@ interface WorldOptions {
|
||||
shape?: Shape;
|
||||
runningArgs?: string;
|
||||
savedModel?: string | null;
|
||||
/** Shell-backed only: the command line of a process between the pane shell and Codex (OpenRig's
|
||||
* staged launch script, or something else). */
|
||||
between?: string;
|
||||
/** The OS's name for that process (ps ucomm); defaults to argv[0]'s basename. */
|
||||
betweenName?: string;
|
||||
activity?: ArbitratedSeatState | null;
|
||||
launch?: HarnessLaunchResult;
|
||||
codexIgnoresSignals?: boolean;
|
||||
@@ -76,7 +82,10 @@ function world(options: WorldOptions = {}) {
|
||||
|
||||
const runningArgs = options.runningArgs ?? `-m gpt-5.5 -s workspace-write resume ${TOKEN}`;
|
||||
let rows: NativeProcessRow[] = shape === "shell_backed"
|
||||
? [shellRow(100, 200), codexRow(200, 100, runningArgs)]
|
||||
? options.between
|
||||
? [shellRow(100, 150), { pid: 150, ppid: 100, pgid: 150, tpgid: 150, executableName: options.betweenName ?? options.between.split(" ")[0]!.split("/").pop()!, command: options.between, startedAt: LSTART },
|
||||
codexRow(200, 150, runningArgs, LSTART, 150)]
|
||||
: [shellRow(100, 200), codexRow(200, 100, runningArgs)]
|
||||
: [codexRow(100, 1, runningArgs, LSTART, 100)];
|
||||
let paneCommand = "codex";
|
||||
let dead = false;
|
||||
@@ -109,7 +118,8 @@ function world(options: WorldOptions = {}) {
|
||||
signalPid: async (pid: number, signal: string): Promise<TmuxResult> => {
|
||||
calls.push(`signalPid ${pid} ${signal}`);
|
||||
maybeThrow("signalPid");
|
||||
if (!options.codexIgnoresSignals) { rows = rows.filter((row) => row.executableName !== "codex"); paneCommand = "bash"; }
|
||||
// The launch script ends with Codex, so its sh exits too.
|
||||
if (!options.codexIgnoresSignals) { rows = rows.filter((row) => row.executableName !== "codex" && row.pid !== 150); paneCommand = "bash"; }
|
||||
return { ok: true };
|
||||
},
|
||||
signalPaneProcess: async (_pane: string, signal: string): Promise<TmuxResult> => {
|
||||
@@ -239,6 +249,34 @@ describe("rig seat restart --keep-conversation: gates before any signal", () =>
|
||||
expect(await cwd.service.restartInPlace({ seatRef: SEAT, plan: true })).toMatchObject({ code: "settings_drift", detail: { differs: ["cwd"] } });
|
||||
});
|
||||
|
||||
it("accepts a Codex launched through OpenRig's staged launch script, and only that script, between shell and Codex", async () => {
|
||||
const staged = "/bin/sh /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt";
|
||||
const w = world({ between: staged });
|
||||
expect(await w.service.restartInPlace({ seatRef: SEAT, plan: true })).toMatchObject({ ok: true, paneShape: "shell_backed" });
|
||||
// macOS (measured on Build): /bin/sh is a shim that runs bash, so ps names the process bash.
|
||||
const mac = world({ between: "/bin/sh /var/folders/7x/abc123/T/openrig-tmux-send-4242-5b249a92-40d6-40c4-b424-57b3b6980529.txt", betweenName: "bash" });
|
||||
expect(await mac.service.restartInPlace({ seatRef: SEAT, plan: true })).toMatchObject({ ok: true, paneShape: "shell_backed" });
|
||||
// The real generator's names are what the matcher accepts.
|
||||
const generated = defaultTmuxFileOps().tmpName();
|
||||
expect(isStagedSendScriptPath(generated), generated).toBe(true);
|
||||
const done = await w.service.restartInPlace({ seatRef: SEAT, reason: "new account" });
|
||||
expect(done).toMatchObject({ ok: true, outcome: "restarted_account_unverified", paneShape: "shell_backed" });
|
||||
expect(w.calls).toContain("signalPid 200 TERM");
|
||||
for (const other of [
|
||||
"/bin/sh /home/u/run-codex.sh",
|
||||
"/bin/sh /tmp/openrig-tmux-send-1.txt",
|
||||
"/bin/sh -c /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt",
|
||||
"/usr/bin/script -q /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt",
|
||||
"/bin/sh tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt",
|
||||
"/bin/sh /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt extra",
|
||||
"/bin/bash /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt",
|
||||
]) {
|
||||
const refused = world({ between: other });
|
||||
expect(await refused.service.restartInPlace({ seatRef: SEAT, plan: true }), other).toMatchObject({ ok: false, code: "pane_shape_unknown" });
|
||||
expect(refused.calls, other).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses when the running Codex is on another conversation, or none can be proven", async () => {
|
||||
const other = world({ runningArgs: "-m gpt-5.5 -s workspace-write resume 019a-other" });
|
||||
expect(await other.service.restartInPlace({ seatRef: SEAT, plan: true })).toMatchObject({ ok: false, code: "resume_binding_unverified" });
|
||||
|
||||
Reference in new issue
Block a user