diff --git a/agents/gizmoduck/work/queue-269/BUILD.md b/agents/gizmoduck/work/queue-269/BUILD.md index 31d0a6059..5732785a3 100644 --- a/agents/gizmoduck/work/queue-269/BUILD.md +++ b/agents/gizmoduck/work/queue-269/BUILD.md @@ -256,6 +256,9 @@ another fence (sage-2707, C3 duckworth-0128). | M7 | the legacy session maps to `sessions//` | the D9 test: the legacy run resumes the sentinel in `threads///session` | | M8 | a non-default session binds its workspace at the legacy `workspaces//` in-sandbox path | T1: `assert.equal` that a named session's in-sandbox workspace is its `session-workspaces///` path, read from the fake adapter's recorded cwd (sage-2717) | | M9 | `#threadRows` back to the default session only | T1: the named session's message starts its run | +| M10 | the `legacy-run-live` refusal removed from check() | the legacy-run-live test: `outcome` equals `[[S, "legacy-run-live"]]` | +| M11 | recover() keeps a survivor only when the outer pid outlived the kill (the runner ignored) | the legacy-run-live test, same assertion | +| M12 | check() reads every survivor, live or not | the legacy-run-live test: after the runner exits, `settle` gives one launch | M6 and the halt fences (C2, duckworth-0150). A plain halt never reaches start(). tick() admits first (:579) and runs no thread pass while halted (:583), and check() admits at :282 and :376. The kill @@ -307,9 +310,17 @@ Written before the first run, which waits for Sage's CLEAR. mayLaunch op halts after the lock is taken. The recheck refuses `processing-halted`, and the test asserts that the lock is gone (startLocked's throw releases it). - The lock's release() runs once. A second call would unlink a lock another host has written since. -- `legacy-run-live` has no test. It fires only for a sessionless entry that is still live after - recover(), which in practice means a bwrap pid that outlived SIGKILL (`survivors`). I can't make - that in a test without faking /proc. +- `legacy-run-live` now has a test (Duckworth's early warning in duckworth-0156). Before, recover() + kept a survivor only when a bwrap pid outlived SIGKILL, which a test can't make. It also ended + a leftover whose runner still ran once the outer pid was gone, so the refusal never saw that run. + Now recover() waits up to 5 s on the outer pid and the runner. It keeps any leftover where + either still runs, and logs `run () from an earlier host still runs`. check() + reads only survivors that still run, so a start goes ahead once the run has exited. The test + writes a sessionless leftover whose runner is a sleep child and whose outer pid is gone. It + asserts the refusal, no launch, no session workspace, and then one launch after the child is + killed. M10-M12 cover it. The registry and pre-recover leftover terms stay. A launching host + can't reach them for a thread instance (check() refuses `session-required` first, and listen() + runs recover() first), but they cost nothing. - N1 (refusedOnce pins `actor_run`) changes a pre-existing test. Under mos-1290 term 5 it goes to Mos first, so the edit stays out of the code commit until Mos rules. diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index df4886210..061bee9b8 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -250,8 +250,9 @@ export function createLauncher({ throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3); } const registry = new Registry(dataRoot, { readOnly: !launches }); - // Row 269: earlier-host runs recover() couldn't kill. They stay live, so check() still sees them. + // Row 269: earlier-host runs still live after recover(). check() sees each while it lives. const survivors = []; + const liveRun = (s) => startTimeOf(s.pid) === s.startTime || startTimeOf(s.runnerPid) === s.runnerStartTime; const children = new Map(); let chain = Promise.resolve(); let closed = false; @@ -305,7 +306,7 @@ export function createLauncher({ if (registry.running(b.id, instance, thread, session)) throw new Refusal("instance-running"); // Row 269 (Q2): a run an earlier host started with no session works in the instance's old // workspace and the thread's store, so no session of the instance starts while one lives. - if (threaded && [...registry.sessions, ...leftover, ...survivors].some((s) => s.business === b.id && s.instance === instance && !s.session)) + if (threaded && [...registry.sessions, ...leftover, ...survivors.filter(liveRun)].some((s) => s.business === b.id && s.instance === instance && !s.session)) throw new Refusal("legacy-run-live", `a run of ${instance} with no session is still live`); let resolved; try { @@ -1019,8 +1020,12 @@ export function createLauncher({ process.kill(s.pid, "SIGKILL"); } catch {} const end = Date.now() + 5000; - while (startTimeOf(s.pid) === s.startTime && Date.now() < end) await new Promise((r) => setTimeout(r, 20)); - if (startTimeOf(s.pid) === s.startTime) survivors.push(s); + while (liveRun(s) && Date.now() < end) await new Promise((r) => setTimeout(r, 20)); + } + // Row 269 (Q2): the session or its runner outlived the kill, or wasn't a session to kill. + if (liveRun(s)) { + survivors.push(s); + log(`run ${s.run} (${s.instance}) from an earlier host still runs`); } try { await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime, ...(s.thread ? { thread: s.thread } : {}), ...(s.session ? { session: s.session } : {}) }); diff --git a/packages/cli/tests/session-launch.test.mjs b/packages/cli/tests/session-launch.test.mjs index aba1795fd..b331eaadd 100644 --- a/packages/cli/tests/session-launch.test.mjs +++ b/packages/cli/tests/session-launch.test.mjs @@ -25,7 +25,7 @@ import { transition } from "../../processing-control/src/state.mjs"; import { gitEnv } from "../../seat/src/checkout.mjs"; import { startTimeOf } from "../../seat/src/proc.mjs"; import { sandboxProblem } from "../../seat/src/sandbox.mjs"; -import { launchLogFile, readSessions, SESSION_CREDENTIALS } from "../../seat/src/session.mjs"; +import { launchLogFile, readSessions, Registry, SESSION_CREDENTIALS } from "../../seat/src/session.mjs"; import { bootConfig, loadSystem } from "../src/config.mjs"; import { createLauncher } from "../src/launcher.mjs"; import { fakeHarnesses, fixture, tmp } from "./helpers.mjs"; @@ -294,6 +294,36 @@ test("REQ-THREAD-2 REQ-LAUNCH-1 a live lock on a session workspace refuses sessi assert.equal(existsSync(lock), false); }); +// Q2 (BUILD.md:177-179, t1-sessions.md:109): an earlier host's sessionless run of the instance is +// still live, so no session of it starts until that run has exited. A sleep child stands in for +// its runner; its outer pid is gone, so recover() has nothing to kill and keeps it as a survivor. +test("REQ-THREAD-2 REQ-LAUNCH-1 a session start refuses legacy-run-live while an earlier host's sessionless run lives, and starts once it has exited", { skip, timeout: 60000 }, async (t) => { + const prepared = prepare(t); + const { f } = prepared; + const runner = spawn("sleep", ["60"], { stdio: "ignore" }); + const gone = new Promise((r) => runner.once("exit", r)); + t.after(() => runner.kill("SIGKILL")); + const runnerStartTime = await until(() => startTimeOf(runner.pid)); + new Registry(f.dataRoot).add({ business: "acme", instance: "assistant", thread: "th-old", run: "run-left", harness: "pi", pid: 2 ** 22 + 7, startTime: "gone", runnerPid: runner.pid, runnerStartTime }); + const host = await start(t, prepared); + assert.ok(host.logs.includes("run run-left (assistant) from an earlier host still runs"), host.logs.join("\n")); + const { A, S } = twoSessions(host.call); + + const toS = send(host.call, A, S, "for S"); + assert.deepEqual(await outcome(f), [[S, "legacy-run-live"]]); + assert.equal(events(f, "refused")[0].thread, A); + assert.deepEqual(events(f, "launch"), [], "nothing spawned"); + assert.deepEqual(readSessions(f.dataRoot), []); + assert.equal(existsSync(sessionWs(f, S)), false, "refused before the workspace"); + + runner.kill("SIGKILL"); + await gone; + assert.equal(await settle(() => events(f, "launch").length), 1, "it starts once the run has exited"); + const saw = await toS.reply(); + assert.deepEqual([saw.before, saw.cwd], [[], sessionWs(f, S)]); + await until(() => events(f, "end").length === 1); +}); + // T2, C3: a symlinked session root refuses session-workspace-unsafe, at the segment check. test("REQ-THREAD-2 REQ-LAUNCH-1 a symlinked session workspace root refuses session-workspace-unsafe and launches nothing", { skip, timeout: 60000 }, async (t) => { const prepared = prepare(t);