@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.
- package/CHANGELOG.md +50 -0
- package/README.md +79 -21
- package/bin/code-forge.mjs +6 -1
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +153 -1
- package/docs/reference/config.md +8 -1
- package/package.json +4 -2
- package/schema/code-forge.schema.json +15 -4
- package/skill/SKILL.md +7 -7
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/adapters/subprocess.md +1 -1
- package/skill/references/code.md +4 -3
- package/skill/references/continuity.md +4 -0
- package/skill/references/decisions.md +5 -2
- package/skill/references/ledger.md +8 -2
- package/skill/references/plan.md +3 -3
- package/skill/references/review.md +8 -5
- package/skill/references/security.md +3 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +36 -0
- package/skill/templates/plan.md +2 -0
- package/src/cli/block.mjs +51 -8
- package/src/cli/jev.mjs +114 -17
- package/src/cli/ledger.mjs +99 -8
- package/src/cli/logs.mjs +193 -58
- package/src/cli/plan.mjs +27 -11
- package/src/cli/report.mjs +15 -1
- package/src/cli/resolve.mjs +16 -1
- package/src/cli/review-file.mjs +10 -5
- package/src/cli/run.mjs +109 -8
- package/src/config/closed-book.mjs +75 -0
- package/src/config/validate.mjs +128 -7
- package/src/decide/escalation.mjs +112 -11
- package/src/decide/questions.mjs +27 -4
- package/src/doctor/probes.mjs +48 -6
- package/src/engines/builders/claude.mjs +4 -1
- package/src/engines/builders/codex.mjs +26 -11
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/efforts.mjs +109 -0
- package/src/engines/sentinel.mjs +34 -0
- package/src/keys/onepassword.mjs +3 -0
- package/src/ledger/report.mjs +66 -29
- package/src/ledger/spend.mjs +171 -0
- package/src/review/engine.mjs +57 -16
- package/src/review/fixloop.mjs +29 -6
- package/src/review/session-retry.mjs +69 -0
- package/src/review/triage.mjs +26 -1
- package/src/review/validate-review.mjs +6 -3
- package/src/session/author.mjs +6 -2
- package/src/session/plan-check.mjs +71 -6
- package/src/session/plan-sections.mjs +98 -0
- package/src/session/spawn.mjs +217 -8
- package/src/state/block.mjs +13 -4
- package/src/state/config-snapshot.mjs +150 -0
- package/src/state/run.mjs +86 -0
- package/src/util/breadcrumbs.mjs +145 -0
- package/src/util/error-hint.mjs +3 -2
- package/src/util/error-kind.mjs +2 -2
- package/src/util/error-log.mjs +109 -18
- package/src/util/forbidden.mjs +3 -1
- package/src/util/known-fixes.json +1 -0
- package/src/util/known-fixes.mjs +153 -0
- package/src/worker/engine.mjs +13 -8
- package/src/worker/loop.mjs +135 -39
- package/src/worker/queue.mjs +68 -7
package/src/worker/loop.mjs
CHANGED
|
@@ -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 =
|
|
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 {
|
|
179
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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);
|
package/src/worker/queue.mjs
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
178
|
-
*
|
|
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
|
}
|