From be31503d22b57345097b211ef7d6dfb029bb82f8 Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 31 Mar 2026 21:49:46 +0800 Subject: [PATCH] fix: serialize local-agent heartbeats --- local-agent/serialized-runner.mjs | 16 ++++++++ local-agent/server.mjs | 5 ++- tests/local-agent-serialized-runner.test.mjs | 39 ++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 local-agent/serialized-runner.mjs create mode 100644 tests/local-agent-serialized-runner.test.mjs diff --git a/local-agent/serialized-runner.mjs b/local-agent/serialized-runner.mjs new file mode 100644 index 0000000..08d19f1 --- /dev/null +++ b/local-agent/serialized-runner.mjs @@ -0,0 +1,16 @@ +export function createSerializedRunner(task) { + let activePromise = null; + + return function runSerialized(...args) { + if (activePromise) { + return activePromise; + } + + activePromise = Promise.resolve(task(...args)) + .finally(() => { + activePromise = null; + }); + + return activePromise; + }; +} diff --git a/local-agent/server.mjs b/local-agent/server.mjs index 2f98e6f..01a4548 100755 --- a/local-agent/server.mjs +++ b/local-agent/server.mjs @@ -7,6 +7,7 @@ import os from "node:os"; import { join, resolve } from "node:path"; import { discoverCodexProjectCandidatesInWorker } from "./codex-session-discovery.mjs"; import { buildCodexTaskExecution } from "./codex-task-runner.mjs"; +import { createSerializedRunner } from "./serialized-runner.mjs"; async function loadConfig(configPath) { const raw = await readFile(resolve(configPath), "utf8"); @@ -493,7 +494,7 @@ const runtime = { lastProjectDiscoverySummary: null, }; -async function heartbeat() { +async function performHeartbeat() { try { const heartbeatProjects = await resolveHeartbeatProjects(config, runtime); const result = await postHeartbeat(config, runtime, heartbeatProjects); @@ -567,6 +568,8 @@ async function heartbeat() { } } +const heartbeat = createSerializedRunner(performHeartbeat); + const server = createServer(async (request, response) => { if (request.url === "/health") { response.writeHead(200, { "Content-Type": "application/json" }); diff --git a/tests/local-agent-serialized-runner.test.mjs b/tests/local-agent-serialized-runner.test.mjs new file mode 100644 index 0000000..c1cb71e --- /dev/null +++ b/tests/local-agent-serialized-runner.test.mjs @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createSerializedRunner } from "../local-agent/serialized-runner.mjs"; + +test("createSerializedRunner coalesces overlapping calls into the same promise", async () => { + let runs = 0; + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + + const runner = createSerializedRunner(async () => { + runs += 1; + await gate; + return { runs }; + }); + + const first = runner(); + const second = runner(); + assert.equal(first, second); + assert.equal(runs, 1); + + release(); + const result = await first; + assert.deepEqual(result, { runs: 1 }); +}); + +test("createSerializedRunner allows the next call after the current run finishes", async () => { + let runs = 0; + const runner = createSerializedRunner(async () => { + runs += 1; + return runs; + }); + + assert.equal(await runner(), 1); + assert.equal(await runner(), 2); + assert.equal(runs, 2); +});