fix(coding-agent/launch): cleared stale readiness on restart entry

readyAt/readyMatch belong to the exited generation but were only reset by
#launch, which runs after the restart backoff delay. During the "restarting"
window the sticky-marker predicate from the prior commit therefore reported a
dead service as ready, letting start and for:"ready" waits race it. Clear both
markers when #settle enters "restarting"; #launch re-sets them once the new
child is up. Adds a regression test that observes the backoff window.

Fixes #6303
This commit is contained in:
roboomp
2026-07-22 16:36:24 +00:00
parent 87552aae30
commit 4a000330c6
2 changed files with 55 additions and 0 deletions
@@ -757,6 +757,11 @@ class DaemonBroker {
const uptime = Date.now() - record.snapshot.startedAt;
record.consecutiveFailures = uptime >= 30_000 ? 0 : record.consecutiveFailures + 1;
record.snapshot.restartCount++;
// Readiness belongs to the exited generation; clear it before the backoff
// so start / for:"ready" waits don't treat a dead service as ready during
// the restart window (readyAt is re-set by #launch once the child is up).
record.snapshot.readyAt = undefined;
record.snapshot.readyMatch = undefined;
record.snapshot.state = "restarting";
const delay = Math.min(1_000 * 2 ** Math.min(record.consecutiveFailures, 5), RESTART_MAX_DELAY_MS);
record.log?.append(
@@ -568,4 +568,54 @@ esac
await shutdown(client);
}
}, 20_000);
// Regression (PR #6305 review): readyAt belongs to the exited generation, so a
// daemon in the restart backoff window must not report readiness. #settle now
// clears readyAt/readyMatch when entering "restarting"; without that, start and
// for:"ready" waits race a dead service during the backoff.
it("clears stale readiness while a daemon is restarting", async () => {
const projectDir = await tempDir("omp-daemon-restart-project-");
const runtimeDir = await tempDir("omp-daemon-restart-runtime-");
const scriptPath = path.join(projectDir, "flap.ts");
// Becomes ready (prints the pattern), then crashes shortly after.
await Bun.write(scriptPath, `process.stdout.write("READY\\n"); setTimeout(() => process.exit(1), 50);\n`);
const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 });
try {
const spec: DaemonSpec = {
name: "flap",
application: process.execPath,
args: [scriptPath],
env: {},
cwd: projectDir,
pty: false,
ready: { log: "READY", timeoutMs: 60_000 },
restart: "on-failure",
persist: false,
detached: false,
};
const started = await client.request({ op: "start", spec });
expect(started.op).toBe("start");
if (started.op !== "start") throw new Error("unexpected start result");
expect(started.daemon.readyAt).toBeDefined();
// Catch the backoff window: once restarting, readiness must be cleared.
const restarting = await waitUntil(async () => {
const listed = await client.request({ op: "list" });
if (listed.op !== "list") return false;
const daemon = listed.daemons.find(d => d.name === "flap");
return daemon?.state === "restarting";
}, 15_000);
expect(restarting).toBeTrue();
const listed = await client.request({ op: "list" });
if (listed.op !== "list") throw new Error("unexpected list result");
const daemon = listed.daemons.find(d => d.name === "flap");
expect(daemon?.state).toBe("restarting");
expect(daemon?.readyAt).toBeUndefined();
expect(daemon?.readyMatch).toBeUndefined();
await client.request({ op: "stop", name: "flap", timeoutMs: 2_000 });
} finally {
await shutdown(client);
}
}, 30_000);
});