@codedology/code-forge 0.3.2 → 0.5.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 +85 -0
- package/README.md +88 -21
- package/bin/code-forge.mjs +6 -1
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +236 -1
- package/docs/reference/config.md +13 -1
- package/package.json +6 -2
- package/schema/code-forge.schema.json +26 -4
- package/skill/SKILL.md +8 -7
- package/skill/references/adapters/claude-code.md +5 -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 +10 -2
- package/skill/references/plan.md +12 -3
- package/skill/references/review.md +13 -6
- 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/facts.mjs +16 -1
- 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/review.mjs +40 -9
- 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/index.mjs +2 -1
- package/src/doctor/local.mjs +15 -0
- 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 +288 -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/facts.mjs +218 -53
- package/src/session/plan-check.mjs +71 -6
- package/src/session/plan-sections.mjs +98 -0
- package/src/session/spawn.mjs +373 -20
- 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/util/locks.mjs +104 -0
- package/src/worker/engine.mjs +122 -40
- package/src/worker/loop.mjs +326 -78
- package/src/worker/queue.mjs +68 -7
package/src/worker/loop.mjs
CHANGED
|
@@ -14,9 +14,23 @@
|
|
|
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
|
+
*
|
|
23
|
+
* Ticket pool (B42): the worker runs up to `review.parallel_tickets` (default 3, 1–16) tickets at
|
|
24
|
+
* once, oldest first; when one ends the next pending one starts. Two tickets for the same
|
|
25
|
+
* (block, file) never overlap (a file lock; the later one stays pending without taking a slot),
|
|
26
|
+
* and the engine takes the block's budget row and L3 rung under a block lock (`engine.mjs`). A
|
|
27
|
+
* throw in one ticket is that ticket's `unavailable` result and never stops the others. `stop()`
|
|
28
|
+
* starts no new ticket and waits for the running ones. With 1 the worker runs exactly as before.
|
|
29
|
+
*
|
|
17
30
|
* Restart: tickets live on disk and are idempotent by content hash, so a new worker simply re-reads
|
|
18
|
-
* every ticket without a done marker. A worker that is stopping
|
|
19
|
-
*
|
|
31
|
+
* every ticket without a done marker. A worker that is stopping waits for its in-flight tickets: one
|
|
32
|
+
* whose session the stop killed (an `unavailable` outcome) is abandoned (no done marker) instead of
|
|
33
|
+
* recorded as a failure; one that reached a real outcome is recorded. The new worker's pid is
|
|
20
34
|
* re-pinned in the run record by the orchestrator (`repinWorker`, = `run start --reattach`).
|
|
21
35
|
*/
|
|
22
36
|
|
|
@@ -27,18 +41,46 @@ import { loadProjectConfig } from '../config/load.mjs';
|
|
|
27
41
|
import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
28
42
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
29
43
|
import { spawnSession } from '../session/spawn.mjs';
|
|
44
|
+
import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
30
45
|
import { readRun, reattachWorker } from '../state/run.mjs';
|
|
31
46
|
import { loadKey, signRow } from '../state/signer.mjs';
|
|
47
|
+
import { keyedLock } from '../util/locks.mjs';
|
|
32
48
|
import { runRoot, setRunRoot } from '../util/tmp.mjs';
|
|
33
49
|
import { reviewTicket } from './engine.mjs';
|
|
34
|
-
import { announceWorker, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
|
|
50
|
+
import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
|
|
35
51
|
import { contentHash, DELETED_HASH, repoRootOf, WorkerError } from './ticket.mjs';
|
|
36
52
|
|
|
37
53
|
/** Name of the key the worker resolves for System 1 (`keys.jev` / `system1.key` reference). */
|
|
38
54
|
export const JEV_KEY_NAME = 'jev';
|
|
39
55
|
|
|
40
56
|
export const DEFAULT_POLL_MS = 250;
|
|
41
|
-
export const DEFAULT_SESSION_TIMEOUT_S =
|
|
57
|
+
export const DEFAULT_SESSION_TIMEOUT_S = 300;
|
|
58
|
+
|
|
59
|
+
/** B42: `review.parallel_tickets` when the config does not set a valid one. */
|
|
60
|
+
export const DEFAULT_PARALLEL_TICKETS = 3;
|
|
61
|
+
export const MAX_PARALLEL_TICKETS = 16;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How many tickets the worker runs at once: `review.parallel_tickets` when it is an integer
|
|
65
|
+
* 1–16, else `DEFAULT_PARALLEL_TICKETS`.
|
|
66
|
+
* @param {Record<string, any> | null | undefined} cfg @returns {number}
|
|
67
|
+
*/
|
|
68
|
+
export function parallelTickets(cfg) {
|
|
69
|
+
const n = cfg?.review?.parallel_tickets;
|
|
70
|
+
return Number.isInteger(n) && n >= 1 && n <= MAX_PARALLEL_TICKETS ? n : DEFAULT_PARALLEL_TICKETS;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The key of the file lock a ticket takes (B42): one ticket per (block, file) at a time.
|
|
75
|
+
* @param {string} block @param {string} file @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export const fileLockKey = (block, file) => `${block}\0${file}`;
|
|
78
|
+
|
|
79
|
+
/** B35: how long `configFor` waits before its one retry of a failed run-record read. */
|
|
80
|
+
export const CONFIG_RETRY_MS = 200;
|
|
81
|
+
|
|
82
|
+
/** The shortest heartbeat interval a worker accepts; anything else falls back to `HEARTBEAT_MS`. */
|
|
83
|
+
export const MIN_HEARTBEAT_MS = 100;
|
|
42
84
|
|
|
43
85
|
/**
|
|
44
86
|
* Provider-CLI auth variables a reviewer CLI may legitimately need (the reviewer runs on the
|
|
@@ -104,12 +146,16 @@ export function loopResult(outcome) {
|
|
|
104
146
|
* @property {string} slug - ledger project slug (the run record's `project`).
|
|
105
147
|
* @property {Buffer} key - the run's HMAC key.
|
|
106
148
|
* @property {number} [pollMs]
|
|
149
|
+
* @property {number} [heartbeatMs] - how often `heartbeat_at` is refreshed: an integer ≥ `MIN_HEARTBEAT_MS`,
|
|
150
|
+
* else `HEARTBEAT_MS`.
|
|
107
151
|
*/
|
|
108
152
|
|
|
109
153
|
/**
|
|
110
154
|
* @typedef {object} WorkerDeps
|
|
111
155
|
* @property {typeof reviewTicket} [review] - the engine hook (default `./engine.mjs`).
|
|
112
156
|
* @property {typeof spawnSession} [spawn]
|
|
157
|
+
* @property {typeof readRun} [readRun] - the run-record read behind the per-ticket config (a test seam).
|
|
158
|
+
* @property {typeof readTicket} [readTicket] - the ticket read (a test seam).
|
|
113
159
|
* @property {import('../keys/store.mjs').KeyStore} [store] - default: B2's production store.
|
|
114
160
|
* @property {any} [opRead]
|
|
115
161
|
* @property {NodeJS.ProcessEnv} [env]
|
|
@@ -130,20 +176,95 @@ export async function createWorker(opts, deps = {}) {
|
|
|
130
176
|
const env = deps.env ?? process.env;
|
|
131
177
|
const review = deps.review ?? reviewTicket;
|
|
132
178
|
const spawn = deps.spawn ?? spawnSession;
|
|
179
|
+
const loadRun = deps.readRun ?? readRun;
|
|
180
|
+
const loadTicket = deps.readTicket ?? readTicket;
|
|
133
181
|
const writeRow = deps.writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug }));
|
|
134
182
|
const readRows = deps.readRows ?? (() => readAllRows(slug));
|
|
135
183
|
const store = deps.store ?? (await createDefaultKeyStore(env));
|
|
136
184
|
const jevKey = await resolveJevKey(cfg, { store, env, opRead: deps.opRead });
|
|
137
185
|
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
186
|
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
|
|
187
|
+
const heartbeatMs = Number.isInteger(opts.heartbeatMs) && /** @type {number} */ (opts.heartbeatMs) >= MIN_HEARTBEAT_MS ? /** @type {number} */ (opts.heartbeatMs) : HEARTBEAT_MS;
|
|
140
188
|
let stopping = false;
|
|
189
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
190
|
+
let heartbeat = null;
|
|
141
191
|
/** @type {(() => void) | null} */
|
|
142
192
|
let wake = null;
|
|
193
|
+
/** @type {(() => void) | null} wakes the pool's wait in `drain` (stop) */
|
|
194
|
+
let poolWake = null;
|
|
143
195
|
|
|
144
196
|
/** @param {Record<string, any>} row */
|
|
145
197
|
const ledger = (row) => writeRow(signRow({ run: runId, ...row }, key));
|
|
146
198
|
|
|
199
|
+
/**
|
|
200
|
+
* The config ticket `id` is reviewed with (B35): its snapshot from the run record, re-read per
|
|
201
|
+
* ticket; the boot `cfg` when the run has no record or the record has no snapshot.
|
|
202
|
+
* @param {string} id
|
|
203
|
+
* @returns {Promise<Record<string, any>>}
|
|
204
|
+
* @throws {Error} `config-snapshot` — a record that cannot be read, or a missing/altered snapshot.
|
|
205
|
+
*/
|
|
206
|
+
async function configFor(id) {
|
|
207
|
+
// The Jev key and the run root were settled at boot from `cfg`; a reload can never change
|
|
208
|
+
// them, because `keys`, `system1.key` and `tmp.root` are fixed keys `run reload` refuses.
|
|
209
|
+
let record;
|
|
210
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
211
|
+
try {
|
|
212
|
+
record = await loadRun(runId);
|
|
213
|
+
break;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (/** @type {any} */ (err)?.code === 'no-run') return cfg;
|
|
216
|
+
// a transient read error (e.g. the record caught mid-replace) gets ONE retry
|
|
217
|
+
if (attempt >= 2) throw new Error('config-snapshot');
|
|
218
|
+
await new Promise((resolve) => setTimeout(resolve, CONFIG_RETRY_MS));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return snapshotFor(record, id)?.config ?? cfg;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* One ticket through the engine hook with `tcfg` (the ticket's config snapshot): the unsigned result.
|
|
226
|
+
* @param {import('./queue.mjs').Ticket} ticket @param {Record<string, any>} base
|
|
227
|
+
* @param {Record<string, any>} tcfg
|
|
228
|
+
* @returns {Promise<Record<string, any>>}
|
|
229
|
+
*/
|
|
230
|
+
async function reviewWith(ticket, base, tcfg) {
|
|
231
|
+
const timeoutS = Number.isInteger(tcfg?.review?.session_timeout_s) && tcfg.review.session_timeout_s > 0 ? tcfg.review.session_timeout_s : DEFAULT_SESSION_TIMEOUT_S;
|
|
232
|
+
/** @type {import('./engine.mjs').ReviewContext} */
|
|
233
|
+
const ctx = {
|
|
234
|
+
repoRoot,
|
|
235
|
+
runId,
|
|
236
|
+
runRootDir,
|
|
237
|
+
cfg: tcfg,
|
|
238
|
+
jevKey,
|
|
239
|
+
key, // signs the fix-loop state (B12c); the sessions never see it (`childEnv`)
|
|
240
|
+
readRows,
|
|
241
|
+
...(deps.jev ? { jev: deps.jev } : {}),
|
|
242
|
+
// `sessionOpts.cfg` (a per-call config, e.g. the consensus second reviewer's level) wins
|
|
243
|
+
// over the ticket's; everything else the worker pins.
|
|
244
|
+
spawn: (sessionOpts) =>
|
|
245
|
+
spawn(
|
|
246
|
+
/** @type {any} */ ({ cfg: tcfg, ...sessionOpts, runRoot: runRootDir, run: runId, block: ticket.block, slug, timeoutMs: timeoutS * 1000 }),
|
|
247
|
+
{ env: childEnv, ...(deps.bins ? { bins: deps.bins } : {}), ...(deps.stderr ? { stderr: deps.stderr } : {}) },
|
|
248
|
+
),
|
|
249
|
+
writeRow: (row) => ledger({ ...row, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash }),
|
|
250
|
+
};
|
|
251
|
+
try {
|
|
252
|
+
const outcome = await review(ticket, ctx);
|
|
253
|
+
return {
|
|
254
|
+
...base,
|
|
255
|
+
status: outcome.status,
|
|
256
|
+
approved: outcome.approved === true,
|
|
257
|
+
engine: outcome.engine,
|
|
258
|
+
...(typeof outcome.reason === 'string' ? { reason: outcome.reason } : {}),
|
|
259
|
+
...(Array.isArray(outcome.findings) ? { findings: outcome.findings } : {}),
|
|
260
|
+
...loopResult(outcome),
|
|
261
|
+
sessions: outcome.sessions,
|
|
262
|
+
};
|
|
263
|
+
} catch {
|
|
264
|
+
return { ...base, status: 'unavailable', reason: 'engine-error', approved: false };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
147
268
|
/**
|
|
148
269
|
* @param {string} id
|
|
149
270
|
* @returns {Promise<Record<string, any> | null>} the signed result, or null when abandoned.
|
|
@@ -153,68 +274,51 @@ export async function createWorker(opts, deps = {}) {
|
|
|
153
274
|
let result;
|
|
154
275
|
let ticket = null;
|
|
155
276
|
try {
|
|
156
|
-
ticket =
|
|
277
|
+
ticket = loadTicket(repoRoot, id);
|
|
157
278
|
} catch (err) {
|
|
158
279
|
result = { event: 'review.result', status: 'refused', reason: err instanceof WorkerError ? err.code : 'bad-ticket', approved: false };
|
|
159
280
|
}
|
|
160
281
|
if (ticket) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
} catch (err) {
|
|
167
|
-
result = { ...base, status: 'refused', reason: err instanceof WorkerError ? err.code : 'unreadable', approved: false };
|
|
168
|
-
}
|
|
169
|
-
if (result) {
|
|
170
|
-
// refused above: the path no longer passes the on-disk check, or the file cannot be read
|
|
171
|
-
} else if (ticket.run !== runId) {
|
|
172
|
-
result = { ...base, status: 'refused', reason: 'other-run', approved: false };
|
|
173
|
-
} else if (current === DELETED_HASH && ticket.content_hash !== DELETED_HASH) {
|
|
174
|
-
result = { ...base, status: 'stale', reason: 'missing-file', approved: false };
|
|
175
|
-
} else if (current !== ticket.content_hash) {
|
|
176
|
-
result = { ...base, status: 'stale', approved: false };
|
|
177
|
-
} 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
|
-
};
|
|
197
|
-
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
|
-
};
|
|
209
|
-
} catch {
|
|
210
|
-
result = { ...base, status: 'unavailable', reason: 'engine-error', approved: false };
|
|
211
|
-
}
|
|
212
|
-
}
|
|
282
|
+
// B42: never two tickets for the same (block, file) at once — its fix-loop state (`seq`) is
|
|
283
|
+
// read, advanced and saved by one ticket at a time; the later one waits here. The key
|
|
284
|
+
// carries the run so two workers in one process (tests) never share a lock.
|
|
285
|
+
const t = ticket;
|
|
286
|
+
result = await keyedLock(`${runId}\0${fileLockKey(t.block, t.file)}`, () => reviewOne(id, t));
|
|
213
287
|
}
|
|
214
|
-
|
|
288
|
+
// Stopping: a ticket whose session our own stop killed ends `unavailable` — leave it queued
|
|
289
|
+
// (no done marker) for the next worker instead of recording the kill as a failure. A ticket
|
|
290
|
+
// that reached a real outcome while the pool drained is recorded as usual.
|
|
291
|
+
if (stopping && result.status === 'unavailable') return null;
|
|
215
292
|
return complete(id, result);
|
|
216
293
|
}
|
|
217
294
|
|
|
295
|
+
/**
|
|
296
|
+
* The unsigned result for a ticket that was read: refused / stale checks, then the review.
|
|
297
|
+
* @param {string} id @param {import('./queue.mjs').Ticket} ticket
|
|
298
|
+
* @returns {Promise<Record<string, any>>}
|
|
299
|
+
*/
|
|
300
|
+
async function reviewOne(id, ticket) {
|
|
301
|
+
const base = { event: 'review.result', block: ticket.block, file: ticket.file, content_hash: ticket.content_hash };
|
|
302
|
+
/** @type {string | null} */
|
|
303
|
+
let current;
|
|
304
|
+
try {
|
|
305
|
+
current = ticket.run === runId ? contentHash(repoRoot, ticket.file) : null;
|
|
306
|
+
} catch (err) {
|
|
307
|
+
// the path no longer passes the on-disk check, or the file cannot be read
|
|
308
|
+
return { ...base, status: 'refused', reason: err instanceof WorkerError ? err.code : 'unreadable', approved: false };
|
|
309
|
+
}
|
|
310
|
+
if (ticket.run !== runId) return { ...base, status: 'refused', reason: 'other-run', approved: false };
|
|
311
|
+
if (current === DELETED_HASH && ticket.content_hash !== DELETED_HASH) return { ...base, status: 'stale', reason: 'missing-file', approved: false };
|
|
312
|
+
if (current !== ticket.content_hash) return { ...base, status: 'stale', approved: false };
|
|
313
|
+
let ticketCfg;
|
|
314
|
+
try {
|
|
315
|
+
ticketCfg = await configFor(id);
|
|
316
|
+
} catch {
|
|
317
|
+
return { ...base, status: 'unavailable', reason: 'config-snapshot', approved: false };
|
|
318
|
+
}
|
|
319
|
+
return reviewWith(ticket, base, ticketCfg);
|
|
320
|
+
}
|
|
321
|
+
|
|
218
322
|
/**
|
|
219
323
|
* Sign and write the result + done marker, then the signed ledger row. A ledger failure does not
|
|
220
324
|
* undo the result: the done marker is what the queue reads.
|
|
@@ -243,33 +347,164 @@ export async function createWorker(opts, deps = {}) {
|
|
|
243
347
|
const poisoned = new Set();
|
|
244
348
|
|
|
245
349
|
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
350
|
+
* One ticket in a pool slot: never rejects. An unexpected throw becomes a signed `unavailable`
|
|
351
|
+
* result (reason `worker-error`) when that can still be written; a ticket whose failure cannot
|
|
352
|
+
* be written either is poisoned (skipped for this worker's life).
|
|
353
|
+
* @param {string} id @returns {Promise<number>} 1 when completed, else 0.
|
|
354
|
+
*/
|
|
355
|
+
async function runOne(id) {
|
|
356
|
+
try {
|
|
357
|
+
return (await processTicket(id)) !== null ? 1 : 0;
|
|
358
|
+
} catch {
|
|
359
|
+
try {
|
|
360
|
+
await complete(id, { event: 'review.result', status: 'unavailable', reason: 'worker-error', approved: false });
|
|
361
|
+
return 1;
|
|
362
|
+
} catch {
|
|
363
|
+
poisoned.add(id);
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* B42: how many tickets run at once — `review.parallel_tickets` of the snapshot in force (so a
|
|
371
|
+
* `run reload` resizes the pool at the next drain), else of the boot config; anything but an
|
|
372
|
+
* integer 1–16 is the default.
|
|
373
|
+
* @returns {Promise<number>}
|
|
374
|
+
*/
|
|
375
|
+
async function poolSize() {
|
|
376
|
+
let current = cfg;
|
|
377
|
+
try {
|
|
378
|
+
current = await configFor('');
|
|
379
|
+
} catch {
|
|
380
|
+
// an unreadable record: the boot config's size (each ticket still fails closed on its own)
|
|
381
|
+
}
|
|
382
|
+
return parallelTickets(current);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The (block, file) key of each ticket read so far, by id: a written ticket never changes (its
|
|
387
|
+
* id is its content's hash), so it is read once for scheduling, however many wake-ups it waits.
|
|
388
|
+
* Dropped once the ticket is done.
|
|
389
|
+
* @type {Map<string, string>}
|
|
390
|
+
*/
|
|
391
|
+
const fileKeys = new Map();
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The (block, file) a pending ticket names, or null when it cannot be read (it is started
|
|
395
|
+
* anyway and refused by `processTicket`; a null is not cached). Only a scheduling hint:
|
|
396
|
+
* `processTicket` takes the file lock on the ticket it actually read.
|
|
397
|
+
* @param {string} id @returns {string | null}
|
|
398
|
+
*/
|
|
399
|
+
function fileKeyOf(id) {
|
|
400
|
+
const known = fileKeys.get(id);
|
|
401
|
+
if (known !== undefined) return known;
|
|
402
|
+
try {
|
|
403
|
+
const t = loadTicket(repoRoot, id);
|
|
404
|
+
const key = fileLockKey(t.block, t.file);
|
|
405
|
+
fileKeys.set(id, key);
|
|
406
|
+
return key;
|
|
407
|
+
} catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Serve every pending ticket (B42): up to `review.parallel_tickets` at once, oldest first; when
|
|
414
|
+
* one finishes, the queue is re-read and the next pending ticket starts (no batch barrier; the
|
|
415
|
+
* queue is also re-read every `pollMs` while tickets run, so a new ticket fills a free slot).
|
|
416
|
+
* A ticket whose (block, file) is already running is left pending (it does not take a slot)
|
|
417
|
+
* and starts once that one ends. Each ticket starts at most once per drain. On `stop()` no new
|
|
418
|
+
* ticket starts; the running ones are awaited before `drain` returns. The pool size (a run-record
|
|
419
|
+
* read) is read only when there is a pending ticket: an idle drain reads nothing but the queue.
|
|
249
420
|
* @returns {Promise<number>} how many were completed.
|
|
250
421
|
*/
|
|
251
422
|
async function drain() {
|
|
423
|
+
/** @type {number | null} */
|
|
424
|
+
let limit = null;
|
|
252
425
|
let done = 0;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
426
|
+
/** @type {Map<string, Promise<void>>} */
|
|
427
|
+
const running = new Map();
|
|
428
|
+
/** @type {Set<string>} the (block, file) keys of the running tickets */
|
|
429
|
+
const busy = new Set();
|
|
430
|
+
/** @type {Set<string>} */
|
|
431
|
+
const started = new Set();
|
|
432
|
+
for (;;) {
|
|
433
|
+
if (!stopping) {
|
|
434
|
+
for (const id of pendingTickets(repoRoot)) {
|
|
435
|
+
if (poisoned.has(id) || started.has(id)) continue;
|
|
436
|
+
if (limit === null) limit = await poolSize();
|
|
437
|
+
if (running.size >= limit || stopping) break;
|
|
438
|
+
const fileKey = fileKeyOf(id);
|
|
439
|
+
if (fileKey !== null && busy.has(fileKey)) continue; // waits without a slot
|
|
440
|
+
started.add(id);
|
|
441
|
+
if (fileKey !== null) busy.add(fileKey);
|
|
442
|
+
const slot = runOne(id)
|
|
443
|
+
.then((n) => {
|
|
444
|
+
done += n;
|
|
445
|
+
})
|
|
446
|
+
.finally(() => {
|
|
447
|
+
// the slot is freed on every exit path (`runOne` never rejects)
|
|
448
|
+
running.delete(id);
|
|
449
|
+
fileKeys.delete(id);
|
|
450
|
+
if (fileKey !== null) busy.delete(fileKey);
|
|
451
|
+
});
|
|
452
|
+
running.set(id, slot);
|
|
264
453
|
}
|
|
265
454
|
}
|
|
455
|
+
if (running.size === 0) break;
|
|
456
|
+
await nextEvent([...running.values()]);
|
|
266
457
|
}
|
|
267
458
|
return done;
|
|
268
459
|
}
|
|
269
460
|
|
|
461
|
+
/**
|
|
462
|
+
* Resolve when one of `slots` settles, `pollMs` passes, or `stop()` is called.
|
|
463
|
+
* @param {Array<Promise<void>>} slots
|
|
464
|
+
*/
|
|
465
|
+
async function nextEvent(slots) {
|
|
466
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
467
|
+
let timer;
|
|
468
|
+
const tick = new Promise((resolve) => {
|
|
469
|
+
timer = setTimeout(resolve, pollMs);
|
|
470
|
+
poolWake = () => resolve(undefined);
|
|
471
|
+
});
|
|
472
|
+
try {
|
|
473
|
+
await Promise.race([...slots, tick]);
|
|
474
|
+
} finally {
|
|
475
|
+
clearTimeout(timer);
|
|
476
|
+
poolWake = null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Announce this worker and keep its heartbeat beating on a timer of its own (B30), independent
|
|
482
|
+
* of the ticket being processed: a review session can run for minutes, and `review-file --wait`
|
|
483
|
+
* must still see the worker as live the whole time.
|
|
484
|
+
*/
|
|
485
|
+
function announce() {
|
|
486
|
+
const self = announceWorker(repoRoot, { pid: process.pid, run: runId });
|
|
487
|
+
stopBeating();
|
|
488
|
+
const timer = setInterval(() => {
|
|
489
|
+
try {
|
|
490
|
+
// another worker announced itself over us: stop beating, never overwrite its file
|
|
491
|
+
if (!beatWorker(repoRoot, self)) stopBeating();
|
|
492
|
+
} catch {
|
|
493
|
+
// a missed beat (an fs error) is retried on the next tick; the poll loop goes on
|
|
494
|
+
}
|
|
495
|
+
}, heartbeatMs);
|
|
496
|
+
timer.unref();
|
|
497
|
+
heartbeat = timer;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function stopBeating() {
|
|
501
|
+
if (heartbeat !== null) clearInterval(heartbeat);
|
|
502
|
+
heartbeat = null;
|
|
503
|
+
}
|
|
504
|
+
|
|
270
505
|
/** Serve the queue until `stop()`. */
|
|
271
506
|
async function run() {
|
|
272
|
-
|
|
507
|
+
announce();
|
|
273
508
|
try {
|
|
274
509
|
while (!stopping) {
|
|
275
510
|
try {
|
|
@@ -294,7 +529,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
294
529
|
|
|
295
530
|
/** Drain the queue once while announced, then release (`worker --once`). */
|
|
296
531
|
async function once() {
|
|
297
|
-
|
|
532
|
+
announce();
|
|
298
533
|
try {
|
|
299
534
|
return await drain();
|
|
300
535
|
} finally {
|
|
@@ -303,13 +538,16 @@ export async function createWorker(opts, deps = {}) {
|
|
|
303
538
|
}
|
|
304
539
|
}
|
|
305
540
|
|
|
541
|
+
/** Stop taking tickets; the running ones are awaited (a killed one is abandoned, see `processTicket`). */
|
|
306
542
|
function stop() {
|
|
307
543
|
stopping = true;
|
|
308
544
|
wake?.();
|
|
545
|
+
poolWake?.();
|
|
309
546
|
}
|
|
310
547
|
|
|
311
548
|
/** Withdraw this process's announcement (`worker.json`) if it is still ours. */
|
|
312
549
|
function release() {
|
|
550
|
+
stopBeating();
|
|
313
551
|
retractWorker(repoRoot, process.pid);
|
|
314
552
|
}
|
|
315
553
|
|
|
@@ -325,11 +563,21 @@ export async function bootWorker(opts, deps = {}) {
|
|
|
325
563
|
const record = await readRun(opts.runId);
|
|
326
564
|
if (record.status !== 'active') throw new WorkerError('run-ended', `run ${opts.runId} has ended`);
|
|
327
565
|
const repoRoot = await repoRootOf(path.resolve(opts.cwd ?? record.workspace));
|
|
328
|
-
|
|
329
|
-
|
|
566
|
+
// B35: a run with a config snapshot boots on it (the snapshot in force, not on whatever the file
|
|
567
|
+
// says now); a run from before snapshots boots on the file, as before.
|
|
568
|
+
let cfg;
|
|
569
|
+
try {
|
|
570
|
+
cfg = snapshotFor(record, '')?.config;
|
|
571
|
+
} catch {
|
|
572
|
+
throw new WorkerError('config', `run ${opts.runId}: the config snapshot in the run record is missing or altered`);
|
|
573
|
+
}
|
|
574
|
+
if (!cfg) {
|
|
575
|
+
const loaded = await loadProjectConfig(repoRoot);
|
|
576
|
+
if (!loaded.ok) throw new WorkerError('config', loaded.message ?? 'the project config cannot be loaded');
|
|
577
|
+
cfg = loaded.config;
|
|
578
|
+
}
|
|
330
579
|
const other = liveWorker(repoRoot);
|
|
331
580
|
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
581
|
const tmpRoot = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
|
|
334
582
|
const runRootDir = runRoot(opts.runId, { root: tmpRoot });
|
|
335
583
|
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
|
}
|