@codedology/code-forge 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +79 -21
  3. package/bin/code-forge.mjs +6 -1
  4. package/docs/README.md +5 -4
  5. package/docs/reference/blocks.json +153 -1
  6. package/docs/reference/config.md +8 -1
  7. package/package.json +4 -2
  8. package/schema/code-forge.schema.json +15 -4
  9. package/skill/SKILL.md +7 -7
  10. package/skill/references/adapters/claude-code.md +1 -1
  11. package/skill/references/adapters/solo.md +1 -1
  12. package/skill/references/adapters/subprocess.md +1 -1
  13. package/skill/references/code.md +4 -3
  14. package/skill/references/continuity.md +4 -0
  15. package/skill/references/decisions.md +5 -2
  16. package/skill/references/ledger.md +8 -2
  17. package/skill/references/plan.md +3 -3
  18. package/skill/references/review.md +8 -5
  19. package/skill/references/security.md +3 -1
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/coder-brief.md +36 -0
  22. package/skill/templates/plan.md +2 -0
  23. package/src/cli/block.mjs +51 -8
  24. package/src/cli/jev.mjs +114 -17
  25. package/src/cli/ledger.mjs +99 -8
  26. package/src/cli/logs.mjs +193 -58
  27. package/src/cli/plan.mjs +27 -11
  28. package/src/cli/report.mjs +15 -1
  29. package/src/cli/resolve.mjs +16 -1
  30. package/src/cli/review-file.mjs +10 -5
  31. package/src/cli/run.mjs +109 -8
  32. package/src/config/closed-book.mjs +75 -0
  33. package/src/config/validate.mjs +128 -7
  34. package/src/decide/escalation.mjs +112 -11
  35. package/src/decide/questions.mjs +27 -4
  36. package/src/doctor/probes.mjs +48 -6
  37. package/src/engines/builders/claude.mjs +4 -1
  38. package/src/engines/builders/codex.mjs +26 -11
  39. package/src/engines/builders/grok.mjs +3 -3
  40. package/src/engines/efforts.mjs +109 -0
  41. package/src/engines/sentinel.mjs +34 -0
  42. package/src/keys/onepassword.mjs +3 -0
  43. package/src/ledger/report.mjs +66 -29
  44. package/src/ledger/spend.mjs +171 -0
  45. package/src/review/engine.mjs +57 -16
  46. package/src/review/fixloop.mjs +29 -6
  47. package/src/review/session-retry.mjs +69 -0
  48. package/src/review/triage.mjs +26 -1
  49. package/src/review/validate-review.mjs +6 -3
  50. package/src/session/author.mjs +6 -2
  51. package/src/session/plan-check.mjs +71 -6
  52. package/src/session/plan-sections.mjs +98 -0
  53. package/src/session/spawn.mjs +217 -8
  54. package/src/state/block.mjs +13 -4
  55. package/src/state/config-snapshot.mjs +150 -0
  56. package/src/state/run.mjs +86 -0
  57. package/src/util/breadcrumbs.mjs +145 -0
  58. package/src/util/error-hint.mjs +3 -2
  59. package/src/util/error-kind.mjs +2 -2
  60. package/src/util/error-log.mjs +109 -18
  61. package/src/util/forbidden.mjs +3 -1
  62. package/src/util/known-fixes.json +1 -0
  63. package/src/util/known-fixes.mjs +153 -0
  64. package/src/worker/engine.mjs +13 -8
  65. package/src/worker/loop.mjs +135 -39
  66. package/src/worker/queue.mjs +68 -7
@@ -14,6 +14,12 @@
14
14
  * Then it polls the queue, hands each ticket to the engine hook (`./engine.mjs`, B12a), signs the
15
15
  * outcome with the run key and writes it with its done marker and a signed ledger row.
16
16
  *
17
+ * Config (B35): the worker reviews each ticket with the run's config snapshot, read from the run
18
+ * record per ticket — the ticket's pin when `run reload` pinned it (it was queued before the
19
+ * reload), else the snapshot in force. So `run reload` reaches the worker without a restart, and a
20
+ * restarted worker boots on the snapshot, not on whatever the file says now. A record from before
21
+ * snapshots (or none) keeps the config the worker booted with.
22
+ *
17
23
  * Restart: tickets live on disk and are idempotent by content hash, so a new worker simply re-reads
18
24
  * every ticket without a done marker. A worker that is stopping abandons its in-flight ticket (no
19
25
  * done marker) instead of recording the killed session as a failure. The new worker's pid is
@@ -27,18 +33,25 @@ import { loadProjectConfig } from '../config/load.mjs';
27
33
  import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
28
34
  import { appendRow, readAllRows } from '../ledger/write.mjs';
29
35
  import { spawnSession } from '../session/spawn.mjs';
36
+ import { snapshotFor } from '../state/config-snapshot.mjs';
30
37
  import { readRun, reattachWorker } from '../state/run.mjs';
31
38
  import { loadKey, signRow } from '../state/signer.mjs';
32
39
  import { runRoot, setRunRoot } from '../util/tmp.mjs';
33
40
  import { reviewTicket } from './engine.mjs';
34
- import { announceWorker, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
41
+ import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
35
42
  import { contentHash, DELETED_HASH, repoRootOf, WorkerError } from './ticket.mjs';
36
43
 
37
44
  /** Name of the key the worker resolves for System 1 (`keys.jev` / `system1.key` reference). */
38
45
  export const JEV_KEY_NAME = 'jev';
39
46
 
40
47
  export const DEFAULT_POLL_MS = 250;
41
- export const DEFAULT_SESSION_TIMEOUT_S = 600;
48
+ export const DEFAULT_SESSION_TIMEOUT_S = 300;
49
+
50
+ /** B35: how long `configFor` waits before its one retry of a failed run-record read. */
51
+ export const CONFIG_RETRY_MS = 200;
52
+
53
+ /** The shortest heartbeat interval a worker accepts; anything else falls back to `HEARTBEAT_MS`. */
54
+ export const MIN_HEARTBEAT_MS = 100;
42
55
 
43
56
  /**
44
57
  * Provider-CLI auth variables a reviewer CLI may legitimately need (the reviewer runs on the
@@ -104,6 +117,8 @@ export function loopResult(outcome) {
104
117
  * @property {string} slug - ledger project slug (the run record's `project`).
105
118
  * @property {Buffer} key - the run's HMAC key.
106
119
  * @property {number} [pollMs]
120
+ * @property {number} [heartbeatMs] - how often `heartbeat_at` is refreshed: an integer ≥ `MIN_HEARTBEAT_MS`,
121
+ * else `HEARTBEAT_MS`.
107
122
  */
108
123
 
109
124
  /**
@@ -135,15 +150,86 @@ export async function createWorker(opts, deps = {}) {
135
150
  const store = deps.store ?? (await createDefaultKeyStore(env));
136
151
  const jevKey = await resolveJevKey(cfg, { store, env, opRead: deps.opRead });
137
152
  const childEnv = sessionEnv(env, [jevKey, key.toString('hex')]);
138
- const timeoutS = Number.isInteger(cfg?.review?.session_timeout_s) && cfg.review.session_timeout_s > 0 ? cfg.review.session_timeout_s : DEFAULT_SESSION_TIMEOUT_S;
139
153
  const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
154
+ const heartbeatMs = Number.isInteger(opts.heartbeatMs) && /** @type {number} */ (opts.heartbeatMs) >= MIN_HEARTBEAT_MS ? /** @type {number} */ (opts.heartbeatMs) : HEARTBEAT_MS;
140
155
  let stopping = false;
156
+ /** @type {ReturnType<typeof setInterval> | null} */
157
+ let heartbeat = null;
141
158
  /** @type {(() => void) | null} */
142
159
  let wake = null;
143
160
 
144
161
  /** @param {Record<string, any>} row */
145
162
  const ledger = (row) => writeRow(signRow({ run: runId, ...row }, key));
146
163
 
164
+ /**
165
+ * The config ticket `id` is reviewed with (B35): its snapshot from the run record, re-read per
166
+ * ticket; the boot `cfg` when the run has no record or the record has no snapshot.
167
+ * @param {string} id
168
+ * @returns {Promise<Record<string, any>>}
169
+ * @throws {Error} `config-snapshot` — a record that cannot be read, or a missing/altered snapshot.
170
+ */
171
+ async function configFor(id) {
172
+ // The Jev key and the run root were settled at boot from `cfg`; a reload can never change
173
+ // them, because `keys`, `system1.key` and `tmp.root` are fixed keys `run reload` refuses.
174
+ let record;
175
+ for (let attempt = 1; ; attempt += 1) {
176
+ try {
177
+ record = await readRun(runId);
178
+ break;
179
+ } catch (err) {
180
+ if (/** @type {any} */ (err)?.code === 'no-run') return cfg;
181
+ // a transient read error (e.g. the record caught mid-replace) gets ONE retry
182
+ if (attempt >= 2) throw new Error('config-snapshot');
183
+ await new Promise((resolve) => setTimeout(resolve, CONFIG_RETRY_MS));
184
+ }
185
+ }
186
+ return snapshotFor(record, id)?.config ?? cfg;
187
+ }
188
+
189
+ /**
190
+ * One ticket through the engine hook with `tcfg` (the ticket's config snapshot): the unsigned result.
191
+ * @param {import('./queue.mjs').Ticket} ticket @param {Record<string, any>} base
192
+ * @param {Record<string, any>} tcfg
193
+ * @returns {Promise<Record<string, any>>}
194
+ */
195
+ async function reviewWith(ticket, base, tcfg) {
196
+ const timeoutS = Number.isInteger(tcfg?.review?.session_timeout_s) && tcfg.review.session_timeout_s > 0 ? tcfg.review.session_timeout_s : DEFAULT_SESSION_TIMEOUT_S;
197
+ /** @type {import('./engine.mjs').ReviewContext} */
198
+ const ctx = {
199
+ repoRoot,
200
+ runId,
201
+ runRootDir,
202
+ cfg: tcfg,
203
+ jevKey,
204
+ key, // signs the fix-loop state (B12c); the sessions never see it (`childEnv`)
205
+ readRows,
206
+ ...(deps.jev ? { jev: deps.jev } : {}),
207
+ // `sessionOpts.cfg` (a per-call config, e.g. the consensus second reviewer's level) wins
208
+ // over the ticket's; everything else the worker pins.
209
+ spawn: (sessionOpts) =>
210
+ spawn(
211
+ /** @type {any} */ ({ cfg: tcfg, ...sessionOpts, runRoot: runRootDir, run: runId, block: ticket.block, slug, timeoutMs: timeoutS * 1000 }),
212
+ { env: childEnv, ...(deps.bins ? { bins: deps.bins } : {}), ...(deps.stderr ? { stderr: deps.stderr } : {}) },
213
+ ),
214
+ writeRow: (row) => ledger({ ...row, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash }),
215
+ };
216
+ try {
217
+ const outcome = await review(ticket, ctx);
218
+ return {
219
+ ...base,
220
+ status: outcome.status,
221
+ approved: outcome.approved === true,
222
+ engine: outcome.engine,
223
+ ...(typeof outcome.reason === 'string' ? { reason: outcome.reason } : {}),
224
+ ...(Array.isArray(outcome.findings) ? { findings: outcome.findings } : {}),
225
+ ...loopResult(outcome),
226
+ sessions: outcome.sessions,
227
+ };
228
+ } catch {
229
+ return { ...base, status: 'unavailable', reason: 'engine-error', approved: false };
230
+ }
231
+ }
232
+
147
233
  /**
148
234
  * @param {string} id
149
235
  * @returns {Promise<Record<string, any> | null>} the signed result, or null when abandoned.
@@ -175,40 +261,14 @@ export async function createWorker(opts, deps = {}) {
175
261
  } else if (current !== ticket.content_hash) {
176
262
  result = { ...base, status: 'stale', approved: false };
177
263
  } else {
178
- /** @type {import('./engine.mjs').ReviewContext} */
179
- const ctx = {
180
- repoRoot,
181
- runId,
182
- runRootDir,
183
- cfg,
184
- jevKey,
185
- key, // signs the fix-loop state (B12c); the sessions never see it (`childEnv`)
186
- readRows,
187
- ...(deps.jev ? { jev: deps.jev } : {}),
188
- // `sessionOpts.cfg` (a per-call config, e.g. the consensus second reviewer's level) wins
189
- // over the worker's; everything else the worker pins.
190
- spawn: (sessionOpts) =>
191
- spawn(
192
- /** @type {any} */ ({ cfg, ...sessionOpts, runRoot: runRootDir, run: runId, block: ticket.block, slug, timeoutMs: timeoutS * 1000 }),
193
- { env: childEnv, ...(deps.bins ? { bins: deps.bins } : {}), ...(deps.stderr ? { stderr: deps.stderr } : {}) },
194
- ),
195
- writeRow: (row) => ledger({ ...row, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash }),
196
- };
264
+ /** @type {Record<string, any> | null} */
265
+ let ticketCfg = null;
197
266
  try {
198
- const outcome = await review(ticket, ctx);
199
- result = {
200
- ...base,
201
- status: outcome.status,
202
- approved: outcome.approved === true,
203
- engine: outcome.engine,
204
- ...(typeof outcome.reason === 'string' ? { reason: outcome.reason } : {}),
205
- ...(Array.isArray(outcome.findings) ? { findings: outcome.findings } : {}),
206
- ...loopResult(outcome),
207
- sessions: outcome.sessions,
208
- };
267
+ ticketCfg = await configFor(id);
209
268
  } catch {
210
- result = { ...base, status: 'unavailable', reason: 'engine-error', approved: false };
269
+ result = { ...base, status: 'unavailable', reason: 'config-snapshot', approved: false };
211
270
  }
271
+ if (ticketCfg) result = await reviewWith(ticket, base, ticketCfg);
212
272
  }
213
273
  }
214
274
  if (stopping) return null; // the session was killed by our own stop: leave the ticket queued
@@ -267,9 +327,34 @@ export async function createWorker(opts, deps = {}) {
267
327
  return done;
268
328
  }
269
329
 
330
+ /**
331
+ * Announce this worker and keep its heartbeat beating on a timer of its own (B30), independent
332
+ * of the ticket being processed: a review session can run for minutes, and `review-file --wait`
333
+ * must still see the worker as live the whole time.
334
+ */
335
+ function announce() {
336
+ const self = announceWorker(repoRoot, { pid: process.pid, run: runId });
337
+ stopBeating();
338
+ const timer = setInterval(() => {
339
+ try {
340
+ // another worker announced itself over us: stop beating, never overwrite its file
341
+ if (!beatWorker(repoRoot, self)) stopBeating();
342
+ } catch {
343
+ // a missed beat (an fs error) is retried on the next tick; the poll loop goes on
344
+ }
345
+ }, heartbeatMs);
346
+ timer.unref();
347
+ heartbeat = timer;
348
+ }
349
+
350
+ function stopBeating() {
351
+ if (heartbeat !== null) clearInterval(heartbeat);
352
+ heartbeat = null;
353
+ }
354
+
270
355
  /** Serve the queue until `stop()`. */
271
356
  async function run() {
272
- announceWorker(repoRoot, { pid: process.pid, run: runId });
357
+ announce();
273
358
  try {
274
359
  while (!stopping) {
275
360
  try {
@@ -294,7 +379,7 @@ export async function createWorker(opts, deps = {}) {
294
379
 
295
380
  /** Drain the queue once while announced, then release (`worker --once`). */
296
381
  async function once() {
297
- announceWorker(repoRoot, { pid: process.pid, run: runId });
382
+ announce();
298
383
  try {
299
384
  return await drain();
300
385
  } finally {
@@ -310,6 +395,7 @@ export async function createWorker(opts, deps = {}) {
310
395
 
311
396
  /** Withdraw this process's announcement (`worker.json`) if it is still ours. */
312
397
  function release() {
398
+ stopBeating();
313
399
  retractWorker(repoRoot, process.pid);
314
400
  }
315
401
 
@@ -325,11 +411,21 @@ export async function bootWorker(opts, deps = {}) {
325
411
  const record = await readRun(opts.runId);
326
412
  if (record.status !== 'active') throw new WorkerError('run-ended', `run ${opts.runId} has ended`);
327
413
  const repoRoot = await repoRootOf(path.resolve(opts.cwd ?? record.workspace));
328
- const loaded = await loadProjectConfig(repoRoot);
329
- if (!loaded.ok) throw new WorkerError('config', loaded.message ?? 'the project config cannot be loaded');
414
+ // B35: a run with a config snapshot boots on it (the snapshot in force, not on whatever the file
415
+ // says now); a run from before snapshots boots on the file, as before.
416
+ let cfg;
417
+ try {
418
+ cfg = snapshotFor(record, '')?.config;
419
+ } catch {
420
+ throw new WorkerError('config', `run ${opts.runId}: the config snapshot in the run record is missing or altered`);
421
+ }
422
+ if (!cfg) {
423
+ const loaded = await loadProjectConfig(repoRoot);
424
+ if (!loaded.ok) throw new WorkerError('config', loaded.message ?? 'the project config cannot be loaded');
425
+ cfg = loaded.config;
426
+ }
330
427
  const other = liveWorker(repoRoot);
331
428
  if (other && other.pid !== process.pid) throw new WorkerError('worker-running', `worker pid ${other.pid} already serves this queue`);
332
- const cfg = loaded.config;
333
429
  const tmpRoot = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
334
430
  const runRootDir = runRoot(opts.runId, { root: tmpRoot });
335
431
  setRunRoot(runRootDir);
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * <repo>/.code-forge/queue/<ticket>.json the ticket (written by `review-file`, idempotent)
6
6
  * <repo>/.code-forge/queue/<ticket>.done the worker's done marker `{ticket, status, result}`
7
- * <repo>/.code-forge/queue/worker.json the worker's announcement `{pid, start_time, run, started_at}`
7
+ * <repo>/.code-forge/queue/worker.json the worker's announcement `{pid, start_time, run, started_at, heartbeat_at}`
8
8
  * <repo>/.code-forge/reviews/<run>/<ticket>.json the SIGNED result (run key HMAC, §8.6)
9
9
  *
10
10
  * The forbidden list (§8.4 ★) denies a coder any write under `reviews/` and to `queue/*.done`;
@@ -20,6 +20,12 @@ import { assertRowPath, assertTicketId, contentHash, ticketId, WorkerError } fro
20
20
 
21
21
  export const WORKER_FILE = 'worker.json';
22
22
 
23
+ /** How often a serving worker refreshes `heartbeat_at` (B30). */
24
+ export const HEARTBEAT_MS = 5000;
25
+
26
+ /** A heartbeat older than this is not fresh (twelve missed beats). */
27
+ export const HEARTBEAT_STALE_MS = 60_000;
28
+
23
29
  /** @param {string} repoRoot */
24
30
  export const queueDir = (repoRoot) => path.join(repoRoot, '.code-forge', 'queue');
25
31
  /** @param {string} repoRoot @param {string} runId */
@@ -156,12 +162,60 @@ export async function verifyResult(repoRoot, runId, ticket) {
156
162
  * @param {string} repoRoot @param {{pid: number, run: string}} who
157
163
  */
158
164
  export function announceWorker(repoRoot, { pid, run }) {
159
- const body = { pid, start_time: readStartTime(pid) ?? UNKNOWN_START_TIME, run, started_at: new Date().toISOString() };
165
+ const now = new Date().toISOString();
166
+ const body = { pid, start_time: readStartTime(pid) ?? UNKNOWN_START_TIME, run, started_at: now, heartbeat_at: now };
160
167
  writeAtomic(path.join(queueDir(repoRoot), WORKER_FILE), `${JSON.stringify(body)}\n`);
161
168
  return body;
162
169
  }
163
170
 
164
- /** @param {string} repoRoot @returns {{pid: number, start_time: string, run: string} | null} */
171
+ /**
172
+ * Refresh the announcement's `heartbeat_at` (B30) while it is still THIS worker's: the file is
173
+ * re-read right before the write and must name the same `pid` AND `start_time` the worker
174
+ * announced. Another worker's announcement is never overwritten (the caller stops beating).
175
+ * The beat is written to a temp file, the announcement re-read and compared once more, and only
176
+ * then renamed over it. The worker calls this from a timer of its own, so it keeps beating while
177
+ * a long review session is awaited.
178
+ * @param {string} repoRoot @param {{pid: number, start_time: string}} self - what `announceWorker` returned.
179
+ * @param {Date} [now]
180
+ * @returns {boolean} whether a beat was written.
181
+ */
182
+ export function beatWorker(repoRoot, self, now = new Date()) {
183
+ const mine = (/** @type {Record<string, any> | null} */ w) => w?.pid === self.pid && w.start_time === self.start_time;
184
+ const w = readWorker(repoRoot);
185
+ if (!mine(w)) return false;
186
+ const file = path.join(queueDir(repoRoot), WORKER_FILE);
187
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
188
+ try {
189
+ writeFileSync(tmp, `${JSON.stringify({ ...w, heartbeat_at: now.toISOString() })}\n`, { mode: 0o644 });
190
+ // re-read right before the rename: an announcement that changed meanwhile is never replaced
191
+ if (!mine(readWorker(repoRoot))) {
192
+ rmSync(tmp, { force: true });
193
+ return false;
194
+ }
195
+ renameSync(tmp, file);
196
+ return true;
197
+ } catch (err) {
198
+ rmSync(tmp, { force: true });
199
+ throw err;
200
+ }
201
+ }
202
+
203
+ /** A heartbeat further than this in the future is not fresh (clock skew tolerance). */
204
+ export const HEARTBEAT_FUTURE_MS = 5000;
205
+
206
+ /**
207
+ * Whether the announcement's heartbeat is fresh: at most `HEARTBEAT_STALE_MS` old and at most
208
+ * `HEARTBEAT_FUTURE_MS` ahead of our clock (a heartbeat far in the future is forged or skewed).
209
+ * @param {{heartbeat_at?: unknown}} w @param {number} [nowMs]
210
+ */
211
+ export function heartbeatFresh(w, nowMs = Date.now()) {
212
+ const at = typeof w?.heartbeat_at === 'string' ? Date.parse(w.heartbeat_at) : NaN;
213
+ if (!Number.isFinite(at)) return false;
214
+ const age = nowMs - at;
215
+ return age <= HEARTBEAT_STALE_MS && age >= -HEARTBEAT_FUTURE_MS;
216
+ }
217
+
218
+ /** @param {string} repoRoot @returns {{pid: number, start_time: string, run: string, heartbeat_at?: string} | null} */
165
219
  export function readWorker(repoRoot) {
166
220
  const w = readJSON(path.join(queueDir(repoRoot), WORKER_FILE));
167
221
  return w && Number.isInteger(w.pid) && w.pid > 1 && typeof w.run === 'string' ? w : null;
@@ -173,13 +227,20 @@ export function retractWorker(repoRoot, pid) {
173
227
  }
174
228
 
175
229
  /**
176
- * The live worker serving this queue, or null (`worker_down`): the announced pid must be alive
177
- * AND still have the announced start time (a recycled pid is not our worker).
178
- * @param {string} repoRoot
230
+ * The live worker serving this queue, or null (`worker_down`): the announced pid must be alive,
231
+ * AND either its heartbeat is fresh (B30: the worker itself wrote it within `HEARTBEAT_STALE_MS`)
232
+ * or it still has the announced start time (a recycled pid is not our worker). A fresh heartbeat
233
+ * wins over the start-time check: `ps` can fail or print another time zone's `lstart` inside a
234
+ * coder's sandbox, which made a live worker look down mid-review (issue #2).
235
+ * Accepted trade-off: a worker that is SIGKILLed (no retraction) leaves a heartbeat that stays
236
+ * fresh for up to `HEARTBEAT_STALE_MS` (60 s); if the OS reuses its pid inside that window, the
237
+ * queue reads as served until the heartbeat goes stale. A clean stop retracts the file at once.
238
+ * @param {string} repoRoot @param {{nowMs?: number}} [opts]
179
239
  */
180
- export function liveWorker(repoRoot) {
240
+ export function liveWorker(repoRoot, opts = {}) {
181
241
  const w = readWorker(repoRoot);
182
242
  if (!w || !isAlive(w.pid)) return null;
243
+ if (heartbeatFresh(w, opts.nowMs)) return w;
183
244
  if (w.start_time !== UNKNOWN_START_TIME && readStartTime(w.pid) !== w.start_time) return null;
184
245
  return w;
185
246
  }