@codedology/code-forge 0.7.7 → 0.9.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 +97 -0
- package/README.md +68 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +390 -1
- package/docs/reference/config.md +26 -8
- package/package.json +1 -1
- package/schema/code-forge.schema.json +81 -4
- package/skill/SKILL.md +6 -6
- package/skill/references/code.md +45 -7
- package/skill/references/ledger.md +8 -4
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +25 -5
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +19 -2
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +427 -41
- package/src/cli/context.mjs +175 -0
- package/src/cli/gates.mjs +125 -15
- package/src/cli/ledger.mjs +46 -2
- package/src/cli/proof.mjs +62 -16
- package/src/cli/report.mjs +7 -4
- package/src/cli/review-file.mjs +324 -18
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +91 -3
- package/src/engines/sentinel.mjs +27 -6
- package/src/gates/detect.mjs +21 -8
- package/src/gates/expand.mjs +202 -0
- package/src/gates/owned-count.mjs +66 -0
- package/src/gates/run.mjs +49 -12
- package/src/install/wizard/answers.mjs +34 -5
- package/src/install/wizard/flags.mjs +8 -1
- package/src/install/wizard/project-file.mjs +2 -0
- package/src/install/wizard/run.mjs +6 -0
- package/src/install/wizard/steps.mjs +24 -4
- package/src/install/wizard/summary.mjs +13 -5
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/outside-review.mjs +164 -0
- package/src/ledger/report.mjs +17 -5
- package/src/ledger/write.mjs +7 -1
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +36 -2
- package/src/review/fixloop.mjs +25 -15
- package/src/review/gate-check.mjs +384 -83
- package/src/review/open-findings.mjs +59 -0
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +103 -39
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +51 -0
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +10 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +46 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +230 -24
- package/src/worker/loop.mjs +29 -8
- package/src/worker/queue.mjs +296 -15
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +46 -6
package/src/worker/queue.mjs
CHANGED
|
@@ -9,10 +9,26 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The forbidden list (§8.4 ★) denies a coder any write under `reviews/` and to `queue/*.done`;
|
|
11
11
|
* the MAC makes an edit visible anyway (T1/T2). Writes are atomic (temp file + rename).
|
|
12
|
+
*
|
|
13
|
+
* B62b: `<repo>` is always the MAIN tree's top level, where the worker runs — a block with its own
|
|
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.
|
|
12
28
|
*/
|
|
13
29
|
|
|
14
|
-
import { randomBytes } from 'node:crypto';
|
|
15
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, 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';
|
|
16
32
|
import path from 'node:path';
|
|
17
33
|
import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
|
|
18
34
|
import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
@@ -54,26 +70,121 @@ function readJSON(file) {
|
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
/**
|
|
57
|
-
* @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: 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
|
|
74
|
+
* `tree` (B62b): the block's worktree the content was hashed in. It is part of the ticket id,
|
|
75
|
+
* and the worker only checks it against the tree it takes from the run record (a ticket whose
|
|
76
|
+
* tree is not that one is `unavailable`, `worktree-mismatch`); it never reads content there.
|
|
58
77
|
*/
|
|
59
78
|
|
|
60
79
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* @
|
|
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.
|
|
65
86
|
*/
|
|
66
|
-
export function
|
|
87
|
+
export function resolveTicket({ repoRoot, run, block, file, tree = null }) {
|
|
67
88
|
const rel = assertRowPath(file);
|
|
68
|
-
const
|
|
69
|
-
const
|
|
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
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Enqueue `file` (already repo-root-relative) for review. Idempotent by content hash: the same
|
|
96
|
+
* content returns the same ticket and never rewrites it. The queue is under `repoRoot`; the
|
|
97
|
+
* content is hashed under `tree` when given (B62b: the block's worktree, realpath'd here), else
|
|
98
|
+
* under `repoRoot`. The tree is part of the ticket id, so a main-tree ticket and a tree ticket for
|
|
99
|
+
* the same bytes differ, and the answer names the tree given here, never a stored value.
|
|
100
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null, now?: Date}} opts
|
|
101
|
+
* @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string}}
|
|
102
|
+
* @throws {WorkerError} `bad-path` for a relative `tree` or one that cannot be resolved.
|
|
103
|
+
*/
|
|
104
|
+
export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date() }) {
|
|
105
|
+
const { rel, root, content_hash, ticket } = resolveTicket({ repoRoot, run, block, file, tree });
|
|
70
106
|
const target = path.join(queueDir(repoRoot), `${ticket}.json`);
|
|
107
|
+
const named = root === null ? {} : { tree: root };
|
|
71
108
|
if (!existsSync(target)) {
|
|
72
109
|
/** @type {Ticket} */
|
|
73
|
-
const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString() };
|
|
110
|
+
const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString(), ...named };
|
|
74
111
|
writeAtomic(target, `${JSON.stringify(body)}\n`);
|
|
75
112
|
}
|
|
76
|
-
return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash };
|
|
113
|
+
return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, ...named };
|
|
114
|
+
}
|
|
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
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {string} tree @returns {string} `tree` realpath'd.
|
|
179
|
+
* @throws {WorkerError} `bad-path` when it is relative or cannot be resolved.
|
|
180
|
+
*/
|
|
181
|
+
function realTree(tree) {
|
|
182
|
+
if (!path.isAbsolute(tree)) throw new WorkerError('bad-path', `the content tree ${tree} is not an absolute path`);
|
|
183
|
+
try {
|
|
184
|
+
return realpathSync(tree);
|
|
185
|
+
} catch {
|
|
186
|
+
throw new WorkerError('bad-path', `the content tree ${tree} cannot be resolved`);
|
|
187
|
+
}
|
|
77
188
|
}
|
|
78
189
|
|
|
79
190
|
/** @param {string} repoRoot @param {string} ticket */
|
|
@@ -81,9 +192,163 @@ export function isDone(repoRoot, ticket) {
|
|
|
81
192
|
return existsSync(path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.done`));
|
|
82
193
|
}
|
|
83
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
|
+
|
|
84
348
|
/**
|
|
85
349
|
* Tickets with no done marker, oldest first (enqueue time, then id). Unparseable files are
|
|
86
|
-
* 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).
|
|
87
352
|
* @param {string} repoRoot
|
|
88
353
|
* @returns {string[]} ticket ids
|
|
89
354
|
*/
|
|
@@ -94,8 +359,9 @@ export function pendingTickets(repoRoot) {
|
|
|
94
359
|
} catch {
|
|
95
360
|
return [];
|
|
96
361
|
}
|
|
362
|
+
const claimed = new Set(names.map((n) => CLAIM.exec(n)?.[1]));
|
|
97
363
|
const ids = names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n)).map((n) => n.slice(0, 24));
|
|
98
|
-
const pending = ids.filter((id) => !names.includes(`${id}.done`));
|
|
364
|
+
const pending = ids.filter((id) => !names.includes(`${id}.done`) && !claimed.has(id));
|
|
99
365
|
const at = (/** @type {string} */ id) => {
|
|
100
366
|
const t = readJSON(path.join(queueDir(repoRoot), `${id}.json`));
|
|
101
367
|
return typeof t?.enqueued_at === 'string' ? t.enqueued_at : '';
|
|
@@ -113,9 +379,24 @@ export function readTicket(repoRoot, id) {
|
|
|
113
379
|
const t = readJSON(path.join(queueDir(repoRoot), `${assertTicketId(id)}.json`));
|
|
114
380
|
if (!t || typeof t !== 'object') throw new WorkerError('bad-ticket', `ticket ${id} is not readable JSON`);
|
|
115
381
|
assertRowPath(t.file);
|
|
116
|
-
if (
|
|
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`);
|
|
383
|
+
if (typeof t.run !== 'string' || typeof t.block !== 'string' || typeof t.content_hash !== 'string') {
|
|
117
384
|
throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
|
|
118
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`);
|
|
119
400
|
return /** @type {Ticket} */ (t);
|
|
120
401
|
}
|
|
121
402
|
|