From 006af71b32db60ece13bb6d90c331964703ffc4a Mon Sep 17 00:00:00 2001 From: Yishuai Li Date: Sun, 8 Mar 2026 03:43:19 +0800 Subject: [PATCH 1/3] fix: touch log file on heartbeat success to prevent stagnation --- src/gep/a2aProtocol.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/gep/a2aProtocol.js b/src/gep/a2aProtocol.js index d44f7d3..27527f6 100644 --- a/src/gep/a2aProtocol.js +++ b/src/gep/a2aProtocol.js @@ -18,7 +18,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); -const { getGepAssetsDir } = require('./paths'); +const { getGepAssetsDir, getLogsDir } = require('./paths'); const { computeAssetId } = require('./contentHash'); const { captureEnvFingerprint } = require('./envFingerprint'); const os = require('os'); @@ -547,6 +547,10 @@ function sendHeartbeat() { _latestAvailableWork = data.available_work; } _heartbeatConsecutiveFailures = 0; + try { + var now = new Date(); + fs.utimesSync(path.join(getLogsDir(), 'evolver_loop.log'), now, now); + } catch (e) {} return { ok: true, response: data }; }) .catch(function (err) { From 0bb83c623aa74efb88fa8c83582533de07903899 Mon Sep 17 00:00:00 2001 From: Yishuai Li Date: Sun, 8 Mar 2026 03:43:31 +0800 Subject: [PATCH 2/3] fix: handle missing log file and dir in heartbeat touch - Add getEvolverLogPath() to paths.js to centralize the log filename, eliminating the duplicate literal between a2aProtocol.js and lifecycle.js - On heartbeat success, mkdirSync the logs dir before touching so a missing directory no longer silently defeats the stagnation fix - Handle ENOENT from utimesSync by creating the file then retrying; other errors now emit console.warn so misconfiguration is visible - Add sendHeartbeat unit tests covering mtime update and file creation Co-Authored-By: Claude Sonnet 4.6 --- src/gep/a2aProtocol.js | 24 ++++++++++++++-- src/gep/paths.js | 5 ++++ src/ops/lifecycle.js | 4 +-- test/a2aProtocol.test.js | 60 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/gep/a2aProtocol.js b/src/gep/a2aProtocol.js index 27527f6..843f88c 100644 --- a/src/gep/a2aProtocol.js +++ b/src/gep/a2aProtocol.js @@ -18,7 +18,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); -const { getGepAssetsDir, getLogsDir } = require('./paths'); +const { getGepAssetsDir, getEvolverLogPath } = require('./paths'); const { computeAssetId } = require('./contentHash'); const { captureEnvFingerprint } = require('./envFingerprint'); const os = require('os'); @@ -548,9 +548,27 @@ function sendHeartbeat() { } _heartbeatConsecutiveFailures = 0; try { + var logPath = getEvolverLogPath(); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); var now = new Date(); - fs.utimesSync(path.join(getLogsDir(), 'evolver_loop.log'), now, now); - } catch (e) {} + try { + fs.utimesSync(logPath, now, now); + } catch (e) { + if (e && e.code === 'ENOENT') { + try { + var fd = fs.openSync(logPath, 'a'); + fs.closeSync(fd); + fs.utimesSync(logPath, now, now); + } catch (innerErr) { + console.warn('[Heartbeat] Failed to create evolver_loop.log: ' + innerErr.message); + } + } else { + console.warn('[Heartbeat] Failed to touch evolver_loop.log: ' + e.message); + } + } + } catch (outerErr) { + console.warn('[Heartbeat] Failed to ensure evolver_loop.log: ' + outerErr.message); + } return { ok: true, response: data }; }) .catch(function (err) { diff --git a/src/gep/paths.js b/src/gep/paths.js index c9b6ad9..129582c 100644 --- a/src/gep/paths.js +++ b/src/gep/paths.js @@ -36,6 +36,10 @@ function getLogsDir() { return process.env.EVOLVER_LOGS_DIR || path.join(getWorkspaceRoot(), 'logs'); } +function getEvolverLogPath() { + return path.join(getLogsDir(), 'evolver_loop.log'); +} + function getMemoryDir() { return process.env.MEMORY_DIR || path.join(getWorkspaceRoot(), 'memory'); } @@ -96,6 +100,7 @@ module.exports = { getRepoRoot, getWorkspaceRoot, getLogsDir, + getEvolverLogPath, getMemoryDir, getEvolutionDir, getGepAssetsDir, diff --git a/src/ops/lifecycle.js b/src/ops/lifecycle.js index 4f44404..82b6ecb 100644 --- a/src/ops/lifecycle.js +++ b/src/ops/lifecycle.js @@ -5,10 +5,10 @@ const fs = require('fs'); const path = require('path'); const { execSync, spawn } = require('child_process'); -const { getRepoRoot, getWorkspaceRoot, getLogsDir } = require('../gep/paths'); +const { getRepoRoot, getWorkspaceRoot, getEvolverLogPath } = require('../gep/paths'); var WORKSPACE_ROOT = getWorkspaceRoot(); -var LOG_FILE = path.join(getLogsDir(), 'evolver_loop.log'); +var LOG_FILE = getEvolverLogPath(); var PID_FILE = path.join(WORKSPACE_ROOT, 'memory', 'evolver_loop.pid'); var MAX_SILENCE_MS = 30 * 60 * 1000; diff --git a/test/a2aProtocol.test.js b/test/a2aProtocol.test.js index 7c43263..95d2924 100644 --- a/test/a2aProtocol.test.js +++ b/test/a2aProtocol.test.js @@ -1,5 +1,8 @@ -const { describe, it } = require('node:test'); +const { describe, it, before, after } = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); const { PROTOCOL_NAME, PROTOCOL_VERSION, @@ -13,6 +16,7 @@ const { buildRevoke, isValidProtocolMessage, unwrapAssetFromMessage, + sendHeartbeat, } = require('../src/gep/a2aProtocol'); describe('protocol constants', () => { @@ -132,3 +136,57 @@ describe('unwrapAssetFromMessage', () => { assert.equal(unwrapAssetFromMessage('string'), null); }); }); + +describe('sendHeartbeat log touch', () => { + var tmpDir; + var originalFetch; + var originalHubUrl; + var originalLogsDir; + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-hb-test-')); + originalHubUrl = process.env.A2A_HUB_URL; + originalLogsDir = process.env.EVOLVER_LOGS_DIR; + process.env.A2A_HUB_URL = 'http://localhost:19999'; + process.env.EVOLVER_LOGS_DIR = tmpDir; + originalFetch = global.fetch; + }); + + after(() => { + global.fetch = originalFetch; + process.env.A2A_HUB_URL = originalHubUrl || ''; + process.env.EVOLVER_LOGS_DIR = originalLogsDir || ''; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('updates mtime of existing evolver_loop.log on successful heartbeat', async () => { + var logPath = path.join(tmpDir, 'evolver_loop.log'); + fs.writeFileSync(logPath, ''); + var oldTime = new Date(Date.now() - 5000); + fs.utimesSync(logPath, oldTime, oldTime); + var beforeMs = Date.now(); + + global.fetch = async () => ({ + json: async () => ({ status: 'ok' }), + }); + + var result = await sendHeartbeat(); + assert.ok(result.ok, 'heartbeat should succeed'); + + var mtime = fs.statSync(logPath).mtimeMs; + assert.ok(mtime >= beforeMs, 'mtime should be updated to at least the time before the call'); + }); + + it('creates evolver_loop.log when it does not exist on successful heartbeat', async () => { + var logPath = path.join(tmpDir, 'evolver_loop.log'); + if (fs.existsSync(logPath)) fs.unlinkSync(logPath); + + global.fetch = async () => ({ + json: async () => ({ status: 'ok' }), + }); + + var result = await sendHeartbeat(); + assert.ok(result.ok, 'heartbeat should succeed'); + assert.ok(fs.existsSync(logPath), 'evolver_loop.log should be created when missing'); + }); +}); From 9def7b32eb82d9f44a9b4bc45fb0a2ad312cce36 Mon Sep 17 00:00:00 2001 From: Yishuai Li Date: Sun, 8 Mar 2026 03:53:23 +0800 Subject: [PATCH 3/3] test: fix env var restore and flaky mtime assertion in heartbeat tests - Delete env vars on teardown when originals were undefined, matching the codebase pattern (avoids '' vs undefined semantic difference) - Assert mtime > oldTime (5s in the past) instead of >= Date.now(), which is stable on filesystems with coarse (1s) timestamp resolution Co-Authored-By: Claude Sonnet 4.6 --- test/a2aProtocol.test.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/test/a2aProtocol.test.js b/test/a2aProtocol.test.js index 95d2924..fc145f7 100644 --- a/test/a2aProtocol.test.js +++ b/test/a2aProtocol.test.js @@ -154,8 +154,16 @@ describe('sendHeartbeat log touch', () => { after(() => { global.fetch = originalFetch; - process.env.A2A_HUB_URL = originalHubUrl || ''; - process.env.EVOLVER_LOGS_DIR = originalLogsDir || ''; + if (originalHubUrl === undefined) { + delete process.env.A2A_HUB_URL; + } else { + process.env.A2A_HUB_URL = originalHubUrl; + } + if (originalLogsDir === undefined) { + delete process.env.EVOLVER_LOGS_DIR; + } else { + process.env.EVOLVER_LOGS_DIR = originalLogsDir; + } fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -164,7 +172,6 @@ describe('sendHeartbeat log touch', () => { fs.writeFileSync(logPath, ''); var oldTime = new Date(Date.now() - 5000); fs.utimesSync(logPath, oldTime, oldTime); - var beforeMs = Date.now(); global.fetch = async () => ({ json: async () => ({ status: 'ok' }), @@ -174,7 +181,7 @@ describe('sendHeartbeat log touch', () => { assert.ok(result.ok, 'heartbeat should succeed'); var mtime = fs.statSync(logPath).mtimeMs; - assert.ok(mtime >= beforeMs, 'mtime should be updated to at least the time before the call'); + assert.ok(mtime > oldTime.getTime(), 'mtime should be newer than the pre-set old time'); }); it('creates evolver_loop.log when it does not exist on successful heartbeat', async () => {