@codedology/code-forge 0.8.0 → 0.10.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 +114 -0
- package/README.md +46 -7
- package/docs/reference/blocks.json +618 -1
- package/docs/reference/config.md +36 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +89 -0
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +21 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +29 -4
- package/skill/references/security.md +5 -5
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +4 -2
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +300 -33
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +264 -13
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +7 -5
- package/src/doctor/local.mjs +116 -8
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/ledger/write.mjs +7 -1
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +117 -31
- package/src/review/fixloop.mjs +70 -28
- package/src/review/gate-check.mjs +378 -80
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/reopen.mjs +256 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/stop-exits.mjs +15 -0
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +11 -1
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +82 -15
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +9 -1
package/src/worker/queue.mjs
CHANGED
|
@@ -12,10 +12,23 @@
|
|
|
12
12
|
*
|
|
13
13
|
* B62b: `<repo>` is always the MAIN tree's top level, where the worker runs — a block with its own
|
|
14
14
|
* worktree still queues there; only the reviewed content is read in the block's tree.
|
|
15
|
+
*
|
|
16
|
+
* B71: an ANSWERS ticket (`kind: 'answers'`, from `review-file <answering file> --answers <ids>
|
|
17
|
+
* --of <source>`) asks the worker whether `by_file` resolves the open findings `answers` of
|
|
18
|
+
* `file` (the source). Its id adds `answers`, `by_file`, `by_hash` and the sorted ids to the
|
|
19
|
+
* normal ticket's parts, so it never collides with a review ticket of the same source content.
|
|
20
|
+
*
|
|
21
|
+
* B75: `review-file --fresh` drops a done marker it classified in place as re-runnable through
|
|
22
|
+
* `dropDoneIf` (an atomic claim by rename, then a byte compare), so two concurrent callers never
|
|
23
|
+
* both drop it. A counted marker the read saw is never moved; only a newer marker the worker wrote
|
|
24
|
+
* between that read and the claim is moved for a moment, then linked straight back (`changed`).
|
|
25
|
+
* B83: a claim that could not be linked back leaves a refused `fresh-restore-failed` marker in its
|
|
26
|
+
* place (or, when that write fails too, the claim itself, which `pendingTickets` treats as done),
|
|
27
|
+
* so a failed restore never hands the worker a ticket to run again; `clearDone` clears both.
|
|
15
28
|
*/
|
|
16
29
|
|
|
17
|
-
import { randomBytes } from 'node:crypto';
|
|
18
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
31
|
+
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
32
|
import path from 'node:path';
|
|
20
33
|
import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
|
|
21
34
|
import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
@@ -57,12 +70,27 @@ function readJSON(file) {
|
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
/**
|
|
60
|
-
* @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, tree?: string}} Ticket
|
|
73
|
+
* @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, tree?: string, kind?: 'answers', by_file?: string, by_hash?: string, answers?: string[]}} Ticket
|
|
61
74
|
* `tree` (B62b): the block's worktree the content was hashed in. It is part of the ticket id,
|
|
62
75
|
* and the worker only checks it against the tree it takes from the run record (a ticket whose
|
|
63
76
|
* tree is not that one is `unavailable`, `worktree-mismatch`); it never reads content there.
|
|
64
77
|
*/
|
|
65
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The review ticket for `file`'s CURRENT content: the one rule `enqueue` and `clearDone` (B72)
|
|
81
|
+
* share — the repo-relative path, the realpath'd tree (or null), the content hash taken under the
|
|
82
|
+
* tree when given (else under `repoRoot`) and the ticket id over all of them.
|
|
83
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null}} opts
|
|
84
|
+
* @returns {{rel: string, root: string | null, content_hash: string, ticket: string}}
|
|
85
|
+
* @throws {WorkerError} `bad-path` for a bad file path or a tree that cannot be resolved.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveTicket({ repoRoot, run, block, file, tree = null }) {
|
|
88
|
+
const rel = assertRowPath(file);
|
|
89
|
+
const root = tree === null ? null : realTree(tree);
|
|
90
|
+
const content_hash = contentHash(root ?? repoRoot, rel);
|
|
91
|
+
return { rel, root, content_hash, ticket: ticketId({ run, block, file: rel, content_hash, tree: root }) };
|
|
92
|
+
}
|
|
93
|
+
|
|
66
94
|
/**
|
|
67
95
|
* Enqueue `file` (already repo-root-relative) for review. Idempotent by content hash: the same
|
|
68
96
|
* content returns the same ticket and never rewrites it. The queue is under `repoRoot`; the
|
|
@@ -74,10 +102,7 @@ function readJSON(file) {
|
|
|
74
102
|
* @throws {WorkerError} `bad-path` for a relative `tree` or one that cannot be resolved.
|
|
75
103
|
*/
|
|
76
104
|
export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date() }) {
|
|
77
|
-
const rel =
|
|
78
|
-
const root = tree === null ? null : realTree(tree);
|
|
79
|
-
const content_hash = contentHash(root ?? repoRoot, rel);
|
|
80
|
-
const ticket = ticketId({ run, block, file: rel, content_hash, tree: root });
|
|
105
|
+
const { rel, root, content_hash, ticket } = resolveTicket({ repoRoot, run, block, file, tree });
|
|
81
106
|
const target = path.join(queueDir(repoRoot), `${ticket}.json`);
|
|
82
107
|
const named = root === null ? {} : { tree: root };
|
|
83
108
|
if (!existsSync(target)) {
|
|
@@ -88,6 +113,67 @@ export function enqueue({ repoRoot, run, block, file, tree = null, now = new Dat
|
|
|
88
113
|
return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, ...named };
|
|
89
114
|
}
|
|
90
115
|
|
|
116
|
+
/** B71: the most finding ids one answers ticket may name. */
|
|
117
|
+
export const MAX_ANSWERS = 10;
|
|
118
|
+
|
|
119
|
+
/** B71: the finding-id shape (the one `block waive` and the review results use). */
|
|
120
|
+
const ANSWER_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* B71: the id of an answers ticket — the normal ticket's parts (run, block, source, source hash,
|
|
124
|
+
* tree) plus `answers`, the answering file, its hash and the sorted ids, so it differs from the
|
|
125
|
+
* normal ticket of the same source content and from an answer of other ids or another file.
|
|
126
|
+
* @param {{run: string, block: string, file: string, content_hash: string, by_file: string, by_hash: string, answers: string[], tree?: string | null}} t
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
export function answersTicketId({ run, block, file, content_hash, by_file, by_hash, answers, tree = null }) {
|
|
130
|
+
const parts = ['answers', run, block, file, content_hash, by_file, by_hash, [...answers].sort().join(','), ...(tree === null ? [] : [tree])];
|
|
131
|
+
return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 24);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* B71: refuse an id list that is not 1..MAX_ANSWERS finding ids; returns them sorted and unique.
|
|
136
|
+
* @param {unknown} answers @returns {string[]}
|
|
137
|
+
* @throws {WorkerError} `usage`
|
|
138
|
+
*/
|
|
139
|
+
export function assertAnswers(answers) {
|
|
140
|
+
if (!Array.isArray(answers) || answers.length === 0) throw new WorkerError('usage', '--answers needs at least one finding id');
|
|
141
|
+
if (answers.some((a) => typeof a !== 'string' || !ANSWER_ID.test(a))) throw new WorkerError('usage', 'a finding id is letters, digits, . _ : - (at most 64)');
|
|
142
|
+
const ids = [...new Set(/** @type {string[]} */ (answers))].sort();
|
|
143
|
+
if (ids.length > MAX_ANSWERS) throw new WorkerError('usage', `--answers takes at most ${MAX_ANSWERS} finding ids`);
|
|
144
|
+
return ids;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @typedef {Ticket & {kind: 'answers', by_file: string, by_hash: string, answers: string[]}} AnswersTicket
|
|
149
|
+
*/
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* B71: enqueue an answers ticket: `byFile` answers the findings `answers` of `file` (both
|
|
153
|
+
* repo-root-relative). Both files are hashed now (under `tree` when given); idempotent like
|
|
154
|
+
* {@link enqueue}. The caller has already refused the same file and the id list.
|
|
155
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, byFile: string, answers: string[], tree?: string | null, now?: Date}} opts
|
|
156
|
+
* @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, by_file: string, by_hash: string, answers: string[], tree?: string}}
|
|
157
|
+
* @throws {WorkerError} `bad-path`, `usage`
|
|
158
|
+
*/
|
|
159
|
+
export function enqueueAnswers({ repoRoot, run, block, file, byFile, answers, tree = null, now = new Date() }) {
|
|
160
|
+
const rel = assertRowPath(file);
|
|
161
|
+
const by_file = assertRowPath(byFile);
|
|
162
|
+
const ids = assertAnswers(answers);
|
|
163
|
+
const root = tree === null ? null : realTree(tree);
|
|
164
|
+
const content_hash = contentHash(root ?? repoRoot, rel);
|
|
165
|
+
const by_hash = contentHash(root ?? repoRoot, by_file);
|
|
166
|
+
const ticket = answersTicketId({ run, block, file: rel, content_hash, by_file, by_hash, answers: ids, tree: root });
|
|
167
|
+
const target = path.join(queueDir(repoRoot), `${ticket}.json`);
|
|
168
|
+
const named = root === null ? {} : { tree: root };
|
|
169
|
+
if (!existsSync(target)) {
|
|
170
|
+
/** @type {AnswersTicket} */
|
|
171
|
+
const body = { ticket, kind: 'answers', run, block, file: rel, content_hash, by_file, by_hash, answers: ids, enqueued_at: now.toISOString(), ...named };
|
|
172
|
+
writeAtomic(target, `${JSON.stringify(body)}\n`);
|
|
173
|
+
}
|
|
174
|
+
return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, by_file, by_hash, answers: ids, ...named };
|
|
175
|
+
}
|
|
176
|
+
|
|
91
177
|
/**
|
|
92
178
|
* @param {string} tree @returns {string} `tree` realpath'd.
|
|
93
179
|
* @throws {WorkerError} `bad-path` when it is relative or cannot be resolved.
|
|
@@ -106,9 +192,163 @@ export function isDone(repoRoot, ticket) {
|
|
|
106
192
|
return existsSync(path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.done`));
|
|
107
193
|
}
|
|
108
194
|
|
|
195
|
+
/** B83: a `dropDoneIf` claim of a done marker (`<ticket>.done.<pid>.<hex>.fresh`). */
|
|
196
|
+
const CLAIM = /^([0-9a-f]{24})\.done\.\d+\.[0-9a-f]{8}\.fresh$/;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* B83: an fs error's code when it is a plain code word, else `unknown` (a message names the code
|
|
200
|
+
* only, never a path). @param {unknown} err @returns {string}
|
|
201
|
+
*/
|
|
202
|
+
function codeOf(err) {
|
|
203
|
+
const code = /** @type {{code?: unknown}} */ (err)?.code;
|
|
204
|
+
return typeof code === 'string' && /^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(code) ? code : 'unknown';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* B72 (`block reopen`, C11): drop the done marker of the review ticket for the file's CURRENT
|
|
209
|
+
* content, so the next `review-file` on it runs instead of returning the cached result. The ticket
|
|
210
|
+
* id comes from `resolveTicket`, the rule `enqueue` uses; no ticket is written. Synchronous.
|
|
211
|
+
* B83: it also removes the ticket's leftover `dropDoneIf` claims (`<ticket>.done.*.fresh`), which
|
|
212
|
+
* would otherwise keep the ticket off the worker for good; `removed` still names the marker only.
|
|
213
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null}} opts
|
|
214
|
+
* @returns {{ticket: string, content_hash: string, removed: boolean}}
|
|
215
|
+
* @throws {WorkerError} `bad-path` for a bad file path or a tree that cannot be resolved.
|
|
216
|
+
*/
|
|
217
|
+
export function clearDone({ repoRoot, run, block, file, tree = null }) {
|
|
218
|
+
const { content_hash, ticket } = resolveTicket({ repoRoot, run, block, file, tree });
|
|
219
|
+
const marker = path.join(queueDir(repoRoot), `${ticket}.done`);
|
|
220
|
+
const removed = existsSync(marker);
|
|
221
|
+
rmSync(marker, { force: true });
|
|
222
|
+
let names;
|
|
223
|
+
try {
|
|
224
|
+
names = readdirSync(queueDir(repoRoot));
|
|
225
|
+
} catch {
|
|
226
|
+
names = [];
|
|
227
|
+
}
|
|
228
|
+
// accepted race: a claim a concurrent dropDoneIf still uses may go here, which can leave a refused
|
|
229
|
+
// fresh-restore-failed marker (rare, fail closed; a second `block reopen` clears it)
|
|
230
|
+
for (const name of names) if (CLAIM.exec(name)?.[1] === ticket) rmSync(path.join(queueDir(repoRoot), name), { force: true });
|
|
231
|
+
return { ticket, content_hash, removed };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* B75 (`review-file --fresh`): drop the done marker of `ticket` only while it still holds `seen`,
|
|
236
|
+
* the bytes the caller already read IN PLACE and classified as re-runnable (a marker classified
|
|
237
|
+
* as counted, tampered or unknown is never passed here, so it is never moved). The queue has no
|
|
238
|
+
* lock file; the order is the guarantee:
|
|
239
|
+
* 1. the caller reads the marker in place and classifies it (a refusal touches nothing);
|
|
240
|
+
* 2. only a re-runnable marker is claimed by an atomic rename to a name of this process — of
|
|
241
|
+
* two concurrent callers only one takes it, the other gets `gone`;
|
|
242
|
+
* 3. the claim's bytes are compared with `seen`: equal, the claim is removed (`dropped`) and the
|
|
243
|
+
* caller enqueues; different (a newer result arrived), the marker is restored with
|
|
244
|
+
* `linkSync` only — on EEXIST a newer marker is there and wins, the claim is removed
|
|
245
|
+
* (`changed`); on any other link error `fresh-restore-failed` is thrown (below).
|
|
246
|
+
* B83, fail closed — no failure leaves a ticket the worker runs again by itself:
|
|
247
|
+
* - a link-back that fails with other than EEXIST writes a refused marker in place,
|
|
248
|
+
* `{ticket, status: 'refused', reason: 'fresh-restore-failed'}` (no-overwrite open `wx`, mode
|
|
249
|
+
* 0o600; it names no result, so `--fresh` counts it and refuses, and `block reopen` clears it).
|
|
250
|
+
* EEXIST on that write: a newer marker is in place. Any other write error keeps the claim; a
|
|
251
|
+
* claim (`<ticket>.done.<pid>.<hex>.fresh`) counts as done for `pendingTickets`, and
|
|
252
|
+
* `clearDone` (`block reopen`) removes it;
|
|
253
|
+
* - a claim that cannot be removed after a `dropped` compare is `fresh-claim-failed` (it would
|
|
254
|
+
* hold the re-queued ticket off the worker; `block reopen` clears it); after a good link-back
|
|
255
|
+
* the marker is back, so a claim that cannot be removed is left for `clearDone`.
|
|
256
|
+
* A crash between the drop and the caller's enqueue leaves a re-runnable ticket with no marker:
|
|
257
|
+
* harmless — its ticket file is pending, and the next `review-file` queues it anyway. Synchronous.
|
|
258
|
+
* @param {string} repoRoot @param {string} ticket @param {Buffer} seen
|
|
259
|
+
* @param {{link?: (from: string, to: string) => void, read?: (file: string) => Buffer, rename?: (from: string, to: string) => void, remove?: (file: string) => void, write?: (file: string, text: string) => void}} [io] - for
|
|
260
|
+
* tests (default `linkSync`, `readFileSync`, `renameSync`, `rmSync` with force, `writeFileSync`
|
|
261
|
+
* with flag `wx` and mode 0o600). A claim that cannot be read is restored the same way and
|
|
262
|
+
* refused `fresh-restore-failed`.
|
|
263
|
+
* @returns {'dropped' | 'gone' | 'changed'}
|
|
264
|
+
* @throws {WorkerError} `fresh-claim-failed` (the claim could not be taken, or not removed after
|
|
265
|
+
* the drop; nothing was queued) or `fresh-restore-failed` (the message says which state was left:
|
|
266
|
+
* put back, a newer marker, a refused marker, the claim kept, or nothing). A message names an
|
|
267
|
+
* error code, never a path.
|
|
268
|
+
*/
|
|
269
|
+
export function dropDoneIf(repoRoot, ticket, seen, io = {}) {
|
|
270
|
+
const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.done`);
|
|
271
|
+
const claim = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.fresh`;
|
|
272
|
+
const remove = io.remove ?? ((/** @type {string} */ file) => rmSync(file, { force: true }));
|
|
273
|
+
try {
|
|
274
|
+
(io.rename ?? renameSync)(marker, claim);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
if (/** @type {{code?: unknown}} */ (err)?.code === 'ENOENT') return 'gone';
|
|
277
|
+
throw new WorkerError('fresh-claim-failed', `the done marker of ticket ${ticket} could not be claimed (${codeOf(err)}); nothing was queued`);
|
|
278
|
+
}
|
|
279
|
+
/** @type {string | null} */
|
|
280
|
+
let readFailed = null;
|
|
281
|
+
/** @type {boolean} */
|
|
282
|
+
let same = false;
|
|
283
|
+
try {
|
|
284
|
+
same = (io.read ?? readFileSync)(claim).equals(seen);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
readFailed = codeOf(err);
|
|
287
|
+
}
|
|
288
|
+
if (same) {
|
|
289
|
+
try {
|
|
290
|
+
remove(claim);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
throw new WorkerError('fresh-claim-failed', `the done marker of ticket ${ticket} was dropped but its claim could not be removed (${codeOf(err)}); block reopen clears it; nothing was queued`);
|
|
293
|
+
}
|
|
294
|
+
return 'dropped';
|
|
295
|
+
}
|
|
296
|
+
/** @type {string | null} */
|
|
297
|
+
let linkFailed = null;
|
|
298
|
+
try {
|
|
299
|
+
(io.link ?? linkSync)(claim, marker);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
linkFailed = codeOf(err);
|
|
302
|
+
}
|
|
303
|
+
const unread = `the claimed done marker of ticket ${ticket} could not be read (${readFailed})`;
|
|
304
|
+
if (linkFailed !== null && linkFailed !== 'EEXIST') {
|
|
305
|
+
const cause = readFailed !== null ? `${unread} and could not be restored (${linkFailed})` : `the done marker of ticket ${ticket} changed and could not be restored (${linkFailed})`;
|
|
306
|
+
throw new WorkerError('fresh-restore-failed', `${cause}; ${refuseInPlace(marker, claim, ticket, io, remove)}; nothing was queued`);
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
remove(claim);
|
|
310
|
+
} catch {
|
|
311
|
+
// the marker is back (or a newer one won), so the ticket is done; `clearDone` removes the claim
|
|
312
|
+
}
|
|
313
|
+
if (readFailed !== null) throw new WorkerError('fresh-restore-failed', `${unread}; ${linkFailed === 'EEXIST' ? 'a newer marker is in place' : 'it was put back'}; nothing was queued`);
|
|
314
|
+
return 'changed';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* B83: after a failed link-back, put a refused `fresh-restore-failed` marker where the done marker
|
|
319
|
+
* was (never over one), so the worker does not run the ticket; the claim is then removed when it
|
|
320
|
+
* can be (`clearDone` removes a leftover one). When the write fails the claim is kept instead.
|
|
321
|
+
* @param {string} marker @param {string} claim @param {string} ticket
|
|
322
|
+
* @param {{write?: (file: string, text: string) => void}} io @param {(file: string) => void} remove
|
|
323
|
+
* @returns {string} which state was left, for the message (codes only, no path).
|
|
324
|
+
*/
|
|
325
|
+
function refuseInPlace(marker, claim, ticket, io, remove) {
|
|
326
|
+
const text = `${JSON.stringify({ ticket, status: 'refused', reason: 'fresh-restore-failed' })}\n`;
|
|
327
|
+
/** @type {string | null} */
|
|
328
|
+
let writeFailed = null;
|
|
329
|
+
try {
|
|
330
|
+
(io.write ?? ((/** @type {string} */ file, /** @type {string} */ body) => writeFileSync(file, body, { flag: 'wx', mode: 0o600 })))(marker, text);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
writeFailed = codeOf(err);
|
|
333
|
+
}
|
|
334
|
+
if (writeFailed === null || writeFailed === 'EEXIST') {
|
|
335
|
+
try {
|
|
336
|
+
remove(claim);
|
|
337
|
+
} catch {
|
|
338
|
+
// the marker holds the ticket off the worker; `clearDone` removes the leftover claim
|
|
339
|
+
}
|
|
340
|
+
return writeFailed === null ? 'a refused fresh-restore-failed marker was written in its place, so the worker does not run the ticket' : 'a newer marker is in place';
|
|
341
|
+
}
|
|
342
|
+
// the claim holds the ticket off the worker (`pendingTickets`); a claim that is gone cannot
|
|
343
|
+
return existsSync(claim)
|
|
344
|
+
? `the refused marker could not be written (${writeFailed}); its claim was kept, so the worker does not run the ticket; block reopen clears it`
|
|
345
|
+
: `the refused marker could not be written (${writeFailed}) and no claim exists`;
|
|
346
|
+
}
|
|
347
|
+
|
|
109
348
|
/**
|
|
110
349
|
* Tickets with no done marker, oldest first (enqueue time, then id). Unparseable files are
|
|
111
|
-
* skipped here and refused by the worker when it reads them.
|
|
350
|
+
* skipped here and refused by the worker when it reads them. B83: a ticket whose marker is held
|
|
351
|
+
* by a `dropDoneIf` claim is not pending either (a claim kept after a failed restore fails closed).
|
|
112
352
|
* @param {string} repoRoot
|
|
113
353
|
* @returns {string[]} ticket ids
|
|
114
354
|
*/
|
|
@@ -119,8 +359,9 @@ export function pendingTickets(repoRoot) {
|
|
|
119
359
|
} catch {
|
|
120
360
|
return [];
|
|
121
361
|
}
|
|
362
|
+
const claimed = new Set(names.map((n) => CLAIM.exec(n)?.[1]));
|
|
122
363
|
const ids = names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n)).map((n) => n.slice(0, 24));
|
|
123
|
-
const pending = ids.filter((id) => !names.includes(`${id}.done`));
|
|
364
|
+
const pending = ids.filter((id) => !names.includes(`${id}.done`) && !claimed.has(id));
|
|
124
365
|
const at = (/** @type {string} */ id) => {
|
|
125
366
|
const t = readJSON(path.join(queueDir(repoRoot), `${id}.json`));
|
|
126
367
|
return typeof t?.enqueued_at === 'string' ? t.enqueued_at : '';
|
|
@@ -139,9 +380,23 @@ export function readTicket(repoRoot, id) {
|
|
|
139
380
|
if (!t || typeof t !== 'object') throw new WorkerError('bad-ticket', `ticket ${id} is not readable JSON`);
|
|
140
381
|
assertRowPath(t.file);
|
|
141
382
|
if (t.tree !== undefined && (typeof t.tree !== 'string' || !path.isAbsolute(t.tree))) throw new WorkerError('bad-ticket', `ticket ${id} names a tree that is not an absolute path`);
|
|
142
|
-
if (typeof t.run !== 'string' || typeof t.block !== 'string' || typeof t.content_hash !== 'string'
|
|
383
|
+
if (typeof t.run !== 'string' || typeof t.block !== 'string' || typeof t.content_hash !== 'string') {
|
|
143
384
|
throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
|
|
144
385
|
}
|
|
386
|
+
if (t.kind !== undefined) {
|
|
387
|
+
// B71: the only other kind is an answers ticket; its id covers the answering file and the ids
|
|
388
|
+
if (t.kind !== 'answers') throw new WorkerError('bad-ticket', `ticket ${id} has an unknown kind`);
|
|
389
|
+
assertRowPath(t.by_file);
|
|
390
|
+
let ids;
|
|
391
|
+
try {
|
|
392
|
+
ids = assertAnswers(t.answers);
|
|
393
|
+
} catch {
|
|
394
|
+
throw new WorkerError('bad-ticket', `ticket ${id} names no valid finding ids`);
|
|
395
|
+
}
|
|
396
|
+
if (typeof t.by_hash !== 'string' || ids.join(',') !== t.answers.join(',') || answersTicketId(t) !== id) throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
|
|
397
|
+
return /** @type {Ticket} */ (t);
|
|
398
|
+
}
|
|
399
|
+
if (ticketId(t) !== id) throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
|
|
145
400
|
return /** @type {Ticket} */ (t);
|
|
146
401
|
}
|
|
147
402
|
|