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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user