@cleocode/core 2026.9.3 → 2026.9.5
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/dist/dispatch/suggested-next.d.ts.map +1 -1
- package/dist/dispatch/suggested-next.js +7 -1
- package/dist/dispatch/suggested-next.js.map +1 -1
- package/dist/docs/export-document.js +84 -0
- package/dist/docs/export-document.js.map +2 -2
- package/dist/hygiene/validate-spawn-readiness.d.ts +88 -4
- package/dist/hygiene/validate-spawn-readiness.d.ts.map +1 -1
- package/dist/hygiene/validate-spawn-readiness.js +138 -16
- package/dist/hygiene/validate-spawn-readiness.js.map +1 -1
- package/dist/injection.d.ts.map +1 -1
- package/dist/injection.js +10 -2
- package/dist/injection.js.map +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -1
- package/dist/internal.js.map +1 -1
- package/dist/llm/plugin-facade.js +84 -0
- package/dist/llm/plugin-facade.js.map +2 -2
- package/dist/release/open.d.ts +11 -18
- package/dist/release/open.d.ts.map +1 -1
- package/dist/release/open.js +135 -7
- package/dist/release/open.js.map +1 -1
- package/dist/release/plan.d.ts +1 -6
- package/dist/release/plan.d.ts.map +1 -1
- package/dist/release/plan.js +1 -8
- package/dist/release/plan.js.map +1 -1
- package/dist/release/reconcile.d.ts +19 -1
- package/dist/release/reconcile.d.ts.map +1 -1
- package/dist/release/reconcile.js +110 -6
- package/dist/release/reconcile.js.map +1 -1
- package/dist/release/release-manifest.d.ts.map +1 -1
- package/dist/release/release-manifest.js +1 -3
- package/dist/release/release-manifest.js.map +1 -1
- package/dist/release/version.d.ts +48 -0
- package/dist/release/version.d.ts.map +1 -0
- package/dist/release/version.js +50 -0
- package/dist/release/version.js.map +1 -0
- package/dist/selfimprove/fix-gen.js +84 -0
- package/dist/selfimprove/fix-gen.js.map +2 -2
- package/dist/setup/sections/brain.d.ts +1 -1
- package/dist/setup/sections/brain.js +1 -1
- package/dist/system/bridge-mode.d.ts +1 -1
- package/dist/system/bridge-mode.js +1 -1
- package/dist/tasks/evidence.d.ts.map +1 -1
- package/dist/tasks/evidence.js +90 -27
- package/dist/tasks/evidence.js.map +1 -1
- package/dist/tasks/heavy-tool-limit.d.ts +119 -0
- package/dist/tasks/heavy-tool-limit.d.ts.map +1 -1
- package/dist/tasks/heavy-tool-limit.js +205 -3
- package/dist/tasks/heavy-tool-limit.js.map +1 -1
- package/dist/tasks/index.d.ts +1 -1
- package/dist/tasks/index.d.ts.map +1 -1
- package/dist/tasks/index.js +1 -1
- package/dist/tasks/index.js.map +1 -1
- package/dist/tasks/tool-cache.d.ts +153 -48
- package/dist/tasks/tool-cache.d.ts.map +1 -1
- package/dist/tasks/tool-cache.js +333 -97
- package/dist/tasks/tool-cache.js.map +1 -1
- package/package.json +13 -13
- package/templates/CLEO-INJECTION.md +6 -6
- package/templates/workflows/release-prepare.yml.tmpl +67 -2
- package/templates/workflows/release-publish.yml.tmpl +1 -1
- package/templates/workflows/release-rollback.yml.tmpl +18 -2
package/dist/tasks/tool-cache.js
CHANGED
|
@@ -31,13 +31,13 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { execFileSync, spawn } from 'node:child_process';
|
|
33
33
|
import { createHash } from 'node:crypto';
|
|
34
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
35
|
-
import { join } from 'node:path';
|
|
34
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
35
|
+
import { join, resolve } from 'node:path';
|
|
36
36
|
import { ExitCode } from '@cleocode/contracts';
|
|
37
37
|
import { CleoError } from '../errors.js';
|
|
38
38
|
import { withLock } from '../store/lock.js';
|
|
39
39
|
import { heavyToolEnv } from './heavy-tool-env.js';
|
|
40
|
-
import { withMemoryLimit } from './heavy-tool-limit.js';
|
|
40
|
+
import { confinementStartupFailure, isSystemdRunCommand, withMemoryLimit, } from './heavy-tool-limit.js';
|
|
41
41
|
import { acquireGlobalSlot } from './tool-semaphore.js';
|
|
42
42
|
// ---------------------------------------------------------------------------
|
|
43
43
|
// Wall-clock deadline resolution (T12105 / gh#1193)
|
|
@@ -156,30 +156,152 @@ export function resolveSpawnTimeoutMs(canonical, env = process.env) {
|
|
|
156
156
|
// ---------------------------------------------------------------------------
|
|
157
157
|
// Cache key derivation
|
|
158
158
|
// ---------------------------------------------------------------------------
|
|
159
|
+
/**
|
|
160
|
+
* The fields that establish WHICH RUN a cache entry describes.
|
|
161
|
+
*
|
|
162
|
+
* This list is the single source of truth for three things that must agree
|
|
163
|
+
* and previously did not, because each was maintained by hand:
|
|
164
|
+
*
|
|
165
|
+
* 1. what {@link computeCacheKey} hashes,
|
|
166
|
+
* 2. what {@link readCacheEntry} requires before serving an entry,
|
|
167
|
+
* 3. what `runToolCached` requires before persisting one.
|
|
168
|
+
*
|
|
169
|
+
* ## Why this exists rather than a fourth guard clause
|
|
170
|
+
*
|
|
171
|
+
* Three defects in three releases, each invisible to the detector written for
|
|
172
|
+
* the one before it:
|
|
173
|
+
*
|
|
174
|
+
* | | gh#1380 (2026.9.2) | gh#1404 (2026.9.3) | gh#1419 (this) |
|
|
175
|
+
* |---|---|---|---|
|
|
176
|
+
* | signature | `exitCode: null` | real code, `head: null` | well-formed, genuinely succeeded |
|
|
177
|
+
* | wrong how | never ran | key cannot rotate | ran, on the wrong tree |
|
|
178
|
+
* | detector | all-null | real code + null head | neither matches |
|
|
179
|
+
*
|
|
180
|
+
* Each fix added one `if` naming one field, to two separate guards — the read
|
|
181
|
+
* path and the write path — that encode the same rule in different code with
|
|
182
|
+
* nothing asserting they agree. A fourth defect means a fourth pair of `if`s,
|
|
183
|
+
* and the pair for defect five is the one somebody edits only half of.
|
|
184
|
+
*
|
|
185
|
+
* The recurring mistake is not any of the three fields. It is that a field
|
|
186
|
+
* could be added to {@link ToolCacheEntry} without being added to the key:
|
|
187
|
+
* `executionRoot` was threaded all the way through execution by T12112 and
|
|
188
|
+
* never reached `computeCacheKey`, so the tool correctly ran in worktree A
|
|
189
|
+
* and its result was then served to worktree B under an identical key. The
|
|
190
|
+
* execution half of that fix landed; the caching half did not.
|
|
191
|
+
*
|
|
192
|
+
* Deriving all three consumers from this one array makes that specific
|
|
193
|
+
* mistake unavailable: a field added here is keyed, required on read, and
|
|
194
|
+
* required on write, together or not at all.
|
|
195
|
+
*
|
|
196
|
+
* ## What is deliberately NOT here
|
|
197
|
+
*
|
|
198
|
+
* `exitCode` is a RESULT, not an identity — it is what the run produced, not
|
|
199
|
+
* which run it was. It has its own non-null requirement in
|
|
200
|
+
* {@link isEntryUsable} (gh#1380: an unknown outcome must never be cached).
|
|
201
|
+
* Putting it here would key the cache on its own answer.
|
|
202
|
+
*
|
|
203
|
+
* @task T12190 (gh#1419)
|
|
204
|
+
*/
|
|
205
|
+
export const TOOL_RUN_IDENTITY_FIELDS = [
|
|
206
|
+
'canonical',
|
|
207
|
+
'cmd',
|
|
208
|
+
'args',
|
|
209
|
+
'head',
|
|
210
|
+
'dirtyFingerprint',
|
|
211
|
+
'executionRoot',
|
|
212
|
+
];
|
|
213
|
+
/**
|
|
214
|
+
* Normalise an execution root to a stable, comparable absolute path.
|
|
215
|
+
*
|
|
216
|
+
* Symlinks are resolved so that two spellings of one directory produce one
|
|
217
|
+
* key — the safe direction, since collapsing an alias can only merge entries
|
|
218
|
+
* that genuinely describe the same tree, while failing to collapse it would
|
|
219
|
+
* split one tree's cache across spellings. A path that cannot be resolved
|
|
220
|
+
* (it has been deleted) falls back to lexical resolution rather than
|
|
221
|
+
* throwing: callers of {@link computeCacheKey} must be able to compute the
|
|
222
|
+
* key of a run whose directory is already gone, precisely so
|
|
223
|
+
* {@link readCacheEntry} can then refuse it.
|
|
224
|
+
*
|
|
225
|
+
* @task T12190 (gh#1419)
|
|
226
|
+
*/
|
|
227
|
+
function normalizeExecutionRoot(executionRoot) {
|
|
228
|
+
try {
|
|
229
|
+
return realpathSync(executionRoot);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return resolve(executionRoot);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
159
235
|
/**
|
|
160
236
|
* Compute the cache key for a resolved tool command + repo state.
|
|
161
237
|
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
238
|
+
* The key covers exactly {@link TOOL_RUN_IDENTITY_FIELDS} — canonical tool
|
|
239
|
+
* name, resolved command and args, git HEAD, dirty-tree fingerprint, and the
|
|
240
|
+
* normalised execution root. Fields are projected in the order that array
|
|
241
|
+
* declares, so the hashed payload is a function of the array and adding a
|
|
242
|
+
* field there changes every key by construction.
|
|
243
|
+
*
|
|
244
|
+
* `executionRoot` is a REQUIRED parameter, deliberately. A defaulted one
|
|
245
|
+
* would let every callsite that was not updated keep computing the old,
|
|
246
|
+
* colliding key while the type-checker reported success — which is the exact
|
|
247
|
+
* shape of the defect this fixes, since `executionRoot` already existed and
|
|
248
|
+
* already reached this function's caller.
|
|
167
249
|
*
|
|
168
250
|
* Using `createHash('sha256')` makes the key collision-resistant and bounded
|
|
169
|
-
* to
|
|
251
|
+
* to 32 hex chars regardless of input size.
|
|
170
252
|
*
|
|
171
253
|
* @task T1534
|
|
254
|
+
* @task T12190 (gh#1419)
|
|
172
255
|
*/
|
|
173
|
-
export function computeCacheKey(command, head, dirtyFingerprint) {
|
|
174
|
-
const
|
|
256
|
+
export function computeCacheKey(command, head, dirtyFingerprint, executionRoot) {
|
|
257
|
+
const identity = {
|
|
175
258
|
canonical: command.canonical,
|
|
176
259
|
cmd: command.cmd,
|
|
177
260
|
args: command.args,
|
|
178
261
|
head,
|
|
179
262
|
dirtyFingerprint,
|
|
180
|
-
|
|
263
|
+
executionRoot: normalizeExecutionRoot(executionRoot),
|
|
264
|
+
};
|
|
265
|
+
// Projected through TOOL_RUN_IDENTITY_FIELDS rather than written as an
|
|
266
|
+
// object literal: the literal is what drifted from the entry shape before.
|
|
267
|
+
const payload = JSON.stringify(TOOL_RUN_IDENTITY_FIELDS.map((f) => identity[f]));
|
|
181
268
|
return createHash('sha256').update(payload).digest('hex').slice(0, 32);
|
|
182
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Whether a cache entry may be SERVED or PERSISTED as a real result.
|
|
272
|
+
*
|
|
273
|
+
* One predicate, used by both {@link readCacheEntry} and the persist guard in
|
|
274
|
+
* `runToolCached`, so the two cannot drift apart. Two rules:
|
|
275
|
+
*
|
|
276
|
+
* 1. every field in {@link TOOL_RUN_IDENTITY_FIELDS} is present and
|
|
277
|
+
* non-null — an entry that cannot say which run it describes is not
|
|
278
|
+
* evidence of anything;
|
|
279
|
+
* 2. `exitCode` is non-null — a run that produced no exit code records that
|
|
280
|
+
* we do not know what happened, and an unknown must never be cached
|
|
281
|
+
* (gh#1380).
|
|
282
|
+
*
|
|
283
|
+
* Rule 1 subsumes gh#1404's `head === null` clause without naming `head`:
|
|
284
|
+
* when the tool runs off a non-git root both git fields are null on EVERY
|
|
285
|
+
* run, so the key is a function of the command alone and nothing a developer
|
|
286
|
+
* does to the source can rotate it. That is not a cache, it is a hardcoded
|
|
287
|
+
* answer with a filename — and unlike gh#1380 it holds a real exit code,
|
|
288
|
+
* which can be a PASS. A poisoned red is investigated within minutes; nobody
|
|
289
|
+
* debugs a passing gate.
|
|
290
|
+
*
|
|
291
|
+
* The cost is stated rather than hidden: a project whose CLEO root is not a
|
|
292
|
+
* git checkout gets no tool-result caching at all. Such projects are not
|
|
293
|
+
* getting valid caching today — they are getting one answer forever.
|
|
294
|
+
*
|
|
295
|
+
* @task T12190 (gh#1419)
|
|
296
|
+
*/
|
|
297
|
+
export function isEntryUsable(entry) {
|
|
298
|
+
for (const field of TOOL_RUN_IDENTITY_FIELDS) {
|
|
299
|
+
const value = entry[field];
|
|
300
|
+
if (value === null || value === undefined)
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
return entry.exitCode !== null && entry.exitCode !== undefined;
|
|
304
|
+
}
|
|
183
305
|
/**
|
|
184
306
|
* Maximum bytes retained from a child's stdout / stderr stream during
|
|
185
307
|
* spawn. The tool-evidence atom only needs the trailing 512 bytes for
|
|
@@ -322,18 +444,29 @@ function spawnCmd(cmd, args, cwd, spawnTimeoutMs, envOverlay) {
|
|
|
322
444
|
if (forceKillTimer !== undefined)
|
|
323
445
|
clearTimeout(forceKillTimer);
|
|
324
446
|
};
|
|
447
|
+
let spawnError = null;
|
|
325
448
|
const finalise = (exitCode, signal) => {
|
|
326
449
|
clearTimers();
|
|
450
|
+
const stderr = stderrBuf.toString();
|
|
327
451
|
resolve({
|
|
328
452
|
exitCode,
|
|
329
453
|
signal,
|
|
330
454
|
stdout: stdoutBuf.toString(),
|
|
331
|
-
|
|
455
|
+
// gh#1397: a child that never started wrote nothing to stderr, so the
|
|
456
|
+
// spawn error IS the only diagnostic that exists. Folding it in here
|
|
457
|
+
// means every downstream consumer gets the reason without each one
|
|
458
|
+
// having to remember a second field.
|
|
459
|
+
stderr: spawnError !== null && stderr === '' ? spawnError : stderr,
|
|
332
460
|
timedOut,
|
|
461
|
+
spawnError,
|
|
333
462
|
});
|
|
334
463
|
};
|
|
335
464
|
// A genuine pre-start failure: ENOENT, EACCES, EAGAIN. No signal, no code.
|
|
336
|
-
|
|
465
|
+
// gh#1397: keep the reason. `(null, null)` says only "nothing ran"; the
|
|
466
|
+
// error object says which of missing / not-executable / out-of-resources
|
|
467
|
+
// it was, and the caller has no other source for that fact.
|
|
468
|
+
child.on('error', (err) => {
|
|
469
|
+
spawnError = err.code ? `${err.code}: ${err.message}` : err.message;
|
|
337
470
|
finalise(null, null);
|
|
338
471
|
});
|
|
339
472
|
// gh#1381: `close` passes `(code, signal)` and exactly one is non-null.
|
|
@@ -373,7 +506,8 @@ export async function captureHead(projectRoot) {
|
|
|
373
506
|
}
|
|
374
507
|
/**
|
|
375
508
|
* Capture a fingerprint of the uncommitted TRACKED tree by sha256-hashing
|
|
376
|
-
* `git
|
|
509
|
+
* `git diff HEAD` over tracked paths (gh#1452 — see the note on the body for
|
|
510
|
+
* why NOT `git status --porcelain`). Returns `null` for
|
|
377
511
|
* non-git roots.
|
|
378
512
|
*
|
|
379
513
|
* Two repos with identical tracked content but different uncommitted edits
|
|
@@ -409,31 +543,82 @@ export async function captureHead(projectRoot) {
|
|
|
409
543
|
* @task T1534
|
|
410
544
|
* @task T12112 (gh#1221)
|
|
411
545
|
*/
|
|
546
|
+
/**
|
|
547
|
+
* Pathspec shared by the fingerprint command.
|
|
548
|
+
*
|
|
549
|
+
* CLEO's own runtime state is excluded so a tracked-and-modified CLEO file
|
|
550
|
+
* cannot self-invalidate the cache it is the key for.
|
|
551
|
+
*/
|
|
552
|
+
const FINGERPRINT_PATHSPEC = [
|
|
553
|
+
'--',
|
|
554
|
+
'.',
|
|
555
|
+
':(exclude).cleo/cache',
|
|
556
|
+
':(exclude).cleo/cache/**',
|
|
557
|
+
':(exclude).cleo/audit',
|
|
558
|
+
':(exclude).cleo/audit/**',
|
|
559
|
+
':(exclude).cleo/backups',
|
|
560
|
+
':(exclude).cleo/backups/**',
|
|
561
|
+
':(exclude).cleo/session-journals',
|
|
562
|
+
':(exclude).cleo/session-journals/**',
|
|
563
|
+
':(exclude).cleo/*.db',
|
|
564
|
+
':(exclude).cleo/*.db-wal',
|
|
565
|
+
':(exclude).cleo/*.db-shm',
|
|
566
|
+
];
|
|
567
|
+
/**
|
|
568
|
+
* Run `git` and hash its stdout as it arrives, without ever holding the whole
|
|
569
|
+
* output in memory.
|
|
570
|
+
*
|
|
571
|
+
* gh#1452: this exists instead of `spawnCmd` because `spawnCmd` accumulates
|
|
572
|
+
* stdout in a {@link TailBuffer} capped at {@link STREAM_TAIL_CAP_BYTES}
|
|
573
|
+
* (64 KiB) and **keeps only the tail**. That is correct for an error tail and
|
|
574
|
+
* catastrophic for a fingerprint: a diff longer than the cap would be hashed
|
|
575
|
+
* from its last 64 KiB only, so a change earlier in the diff would not move the
|
|
576
|
+
* key. Measured: 200 small changed files produce ~30 KB of diff, so a shared
|
|
577
|
+
* working tree reaches the cap at roughly 430 — precisely the large-dirty-tree
|
|
578
|
+
* case gh#1452 was reported from. A fix built on `spawnCmd` would pass on a
|
|
579
|
+
* small repo and fail exactly where it matters.
|
|
580
|
+
*
|
|
581
|
+
* @param args - argv after `git`.
|
|
582
|
+
* @param cwd - directory to run in.
|
|
583
|
+
* @returns 32 hex chars of sha256 over stdout, or `null` if git exited non-zero
|
|
584
|
+
* or could not be spawned.
|
|
585
|
+
*
|
|
586
|
+
* @task T12218 (gh#1452)
|
|
587
|
+
*/
|
|
588
|
+
function hashGitStdout(args, cwd) {
|
|
589
|
+
return new Promise((resolve) => {
|
|
590
|
+
const hash = createHash('sha256');
|
|
591
|
+
const child = spawn('git', [...args], { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
592
|
+
child.stdout?.on('data', (chunk) => hash.update(chunk));
|
|
593
|
+
child.on('error', () => resolve(null));
|
|
594
|
+
child.on('close', (code) => {
|
|
595
|
+
resolve(code === 0 ? hash.digest('hex').slice(0, 32) : null);
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
}
|
|
412
599
|
export async function captureDirtyFingerprint(projectRoot) {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
return null;
|
|
436
|
-
return createHash('sha256').update(r.stdout).digest('hex').slice(0, 32);
|
|
600
|
+
// gh#1452: `git diff HEAD`, NOT `git status --porcelain`.
|
|
601
|
+
//
|
|
602
|
+
// Porcelain reports WHICH paths are dirty and never WHAT is in them, so it is
|
|
603
|
+
// a summary of dirtiness rather than an identity for it. Measured in a
|
|
604
|
+
// scratch repo — a broken edit and its fix to the same tracked file both
|
|
605
|
+
// produce ` M src.ts`, hashing to the identical fingerprint. The key
|
|
606
|
+
// therefore could not rotate when a failure's cause was fixed, and the stale
|
|
607
|
+
// FAILED entry was replayed indefinitely: the reported symptom was an
|
|
608
|
+
// identical error text with `(cached)` in ~700 ms across four task ids, with
|
|
609
|
+
// the only cure being to delete the cache file by hand.
|
|
610
|
+
//
|
|
611
|
+
// `--raw` is NOT an alternative, though it looks like one: for an UNSTAGED
|
|
612
|
+
// edit git reports the destination blob as `0000000`, so
|
|
613
|
+
// `:100644 100644 4b48dee 0000000 M\ta.txt` is byte-identical before and
|
|
614
|
+
// after the fix. It is the same summary defect one layer down.
|
|
615
|
+
//
|
|
616
|
+
// Still TRACKED-only (`diff HEAD` ignores untracked), so the deliberate
|
|
617
|
+
// gh#1221 tradeoff documented above is unchanged — including its escape
|
|
618
|
+
// hatch, `CLEO_EVIDENCE_FRESH=1`. Widening this to untracked content would
|
|
619
|
+
// trade a stale-FAILED bug for the every-tool-invalidates-its-own-cache bug
|
|
620
|
+
// that tradeoff exists to prevent.
|
|
621
|
+
return hashGitStdout(['diff', 'HEAD', ...FINGERPRINT_PATHSPEC], projectRoot);
|
|
437
622
|
}
|
|
438
623
|
// ---------------------------------------------------------------------------
|
|
439
624
|
// Cache directory + entry IO
|
|
@@ -467,47 +652,37 @@ export function readCacheEntry(projectRoot, key) {
|
|
|
467
652
|
return null;
|
|
468
653
|
try {
|
|
469
654
|
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
470
|
-
|
|
655
|
+
// Schema gate. Bumped to 2 by gh#1419; every entry written before
|
|
656
|
+
// `executionRoot` was part of a run's identity is refused here, on this
|
|
657
|
+
// one line. gh#1380 and gh#1404 each needed exactly this and each
|
|
658
|
+
// reimplemented it as a bespoke null-check on their own field, while this
|
|
659
|
+
// counter sat unused at 1 through both.
|
|
660
|
+
if (parsed.schemaVersion !== 2 || parsed.key !== key)
|
|
471
661
|
return null;
|
|
472
662
|
// A placeholder written by `runToolCached` to satisfy proper-lockfile's
|
|
473
663
|
// "file must exist" requirement. It carries no real result — treat as
|
|
474
|
-
// a miss until the lock holder writes the entry.
|
|
664
|
+
// a miss until the lock holder writes the entry. Checked before the
|
|
665
|
+
// identity gate below because a placeholder legitimately has none.
|
|
475
666
|
if (parsed.pending === true)
|
|
476
667
|
return null;
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
// non-git root, `head` and `dirtyFingerprint` are both null, they are part
|
|
482
|
-
// of the cache key, and a key that cannot change can never be invalidated
|
|
483
|
-
// by a commit or an edit. One signal-killed run would otherwise serve a
|
|
484
|
-
// cached "binary missing" forever, without ever spawning again — a
|
|
485
|
-
// permanent red that no amount of correct work clears.
|
|
486
|
-
if (parsed.exitCode === null || parsed.exitCode === undefined)
|
|
668
|
+
// The identity + result gate (gh#1380, gh#1404, gh#1419), derived from
|
|
669
|
+
// TOOL_RUN_IDENTITY_FIELDS rather than written out field by field, and
|
|
670
|
+
// shared verbatim with the persist guard in `runToolCached`.
|
|
671
|
+
if (!isEntryUsable(parsed))
|
|
487
672
|
return null;
|
|
488
|
-
// gh#
|
|
489
|
-
// `computeCacheKey` hashes {canonical, cmd, args, head, dirtyFingerprint},
|
|
490
|
-
// so with both git fields null nothing a developer does to the SOURCE can
|
|
491
|
-
// change the key. Editing does not. Committing does not. Only editing the
|
|
492
|
-
// tool command itself does, which is a config change, not a code change.
|
|
493
|
-
//
|
|
494
|
-
// That is not a cache; it is a hardcoded answer with a filename. And
|
|
495
|
-
// unlike gh#1380's `exitCode: null` — which fails closed and is therefore
|
|
496
|
-
// loud — this holds a REAL exit code of either sign. A cached PASS is not
|
|
497
|
-
// self-announcing: nobody debugs a passing gate.
|
|
673
|
+
// gh#1419: refuse a hit whose originating tree is GONE.
|
|
498
674
|
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
// key. Revert that command and `tool:test` returns 0 without spawning.
|
|
675
|
+
// Keying on `executionRoot` stops one live worktree from being served
|
|
676
|
+
// another live worktree's result. It does not address the worse case the
|
|
677
|
+
// field survey actually found: 8 entries produced in worktrees that had
|
|
678
|
+
// since been deleted, all `exitCode: 0`, all still servable. A stale
|
|
679
|
+
// result is merely wrong; a result whose tree no longer exists is
|
|
680
|
+
// UNFALSIFIABLE — there is nowhere left to go and check what ran.
|
|
506
681
|
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
if (parsed.
|
|
682
|
+
// This is the one rule that is genuinely read-only. At write time the
|
|
683
|
+
// directory exists by construction, so it cannot live in `isEntryUsable`
|
|
684
|
+
// alongside the rules both paths share.
|
|
685
|
+
if (!existsSync(parsed.executionRoot))
|
|
511
686
|
return null;
|
|
512
687
|
return parsed;
|
|
513
688
|
}
|
|
@@ -574,7 +749,7 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
574
749
|
const bypassCache = opts.bypassCache ?? process.env['CLEO_EVIDENCE_FRESH'] === '1';
|
|
575
750
|
const head = await captureHead(executionRoot);
|
|
576
751
|
const dirtyFingerprint = await captureDirtyFingerprint(executionRoot);
|
|
577
|
-
const key = computeCacheKey(command, head, dirtyFingerprint);
|
|
752
|
+
const key = computeCacheKey(command, head, dirtyFingerprint, executionRoot);
|
|
578
753
|
// Fast path — fresh cache hit
|
|
579
754
|
if (!bypassCache) {
|
|
580
755
|
const existing = readCacheEntry(projectRoot, key);
|
|
@@ -588,6 +763,7 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
588
763
|
cacheHit: true,
|
|
589
764
|
timedOut: false,
|
|
590
765
|
lockBusy: false,
|
|
766
|
+
harnessFailure: null,
|
|
591
767
|
executionRoot,
|
|
592
768
|
entry: existing,
|
|
593
769
|
};
|
|
@@ -609,7 +785,7 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
609
785
|
ensureCacheDir(projectRoot);
|
|
610
786
|
const cachePath = cacheEntryPath(projectRoot, key);
|
|
611
787
|
if (!existsSync(cachePath)) {
|
|
612
|
-
writeFileSync(cachePath, JSON.stringify({ schemaVersion:
|
|
788
|
+
writeFileSync(cachePath, JSON.stringify({ schemaVersion: 2, key, pending: true }), 'utf-8');
|
|
613
789
|
}
|
|
614
790
|
const releaseSemaphore = opts.skipGlobalSemaphore
|
|
615
791
|
? undefined
|
|
@@ -630,6 +806,7 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
630
806
|
cacheHit: true,
|
|
631
807
|
timedOut: false,
|
|
632
808
|
lockBusy: false,
|
|
809
|
+
harnessFailure: null,
|
|
633
810
|
executionRoot,
|
|
634
811
|
entry: fresh,
|
|
635
812
|
};
|
|
@@ -644,7 +821,12 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
644
821
|
// see. Denying swap is the point: the failure this guards against was
|
|
645
822
|
// a throttle-and-thrash host freeze, not an OOM kill. Degrades to an
|
|
646
823
|
// unwrapped spawn off Linux or without a user systemd manager.
|
|
647
|
-
const limited = withMemoryLimit(command.canonical, command.cmd, command.args
|
|
824
|
+
const limited = withMemoryLimit(command.canonical, command.cmd, command.args, {
|
|
825
|
+
// The scope name's rootHash8 comes off the SAME execution root the
|
|
826
|
+
// cache key uses (T12112 / gh#1220), so the scope and the cache agree
|
|
827
|
+
// on what "the tree under test" means.
|
|
828
|
+
executionRoot,
|
|
829
|
+
});
|
|
648
830
|
const startedAt = Date.now();
|
|
649
831
|
const result = await spawnCmd(limited.cmd, [...limited.args], executionRoot, spawnTimeoutMs, heavyToolEnv(command.canonical));
|
|
650
832
|
const durationMs = Date.now() - startedAt;
|
|
@@ -662,9 +844,10 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
662
844
|
cacheHit: false,
|
|
663
845
|
timedOut: true,
|
|
664
846
|
lockBusy: false,
|
|
847
|
+
harnessFailure: null,
|
|
665
848
|
executionRoot,
|
|
666
849
|
entry: {
|
|
667
|
-
schemaVersion:
|
|
850
|
+
schemaVersion: 2,
|
|
668
851
|
key,
|
|
669
852
|
canonical: command.canonical,
|
|
670
853
|
displayName: command.displayName,
|
|
@@ -673,6 +856,7 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
673
856
|
source: command.source,
|
|
674
857
|
head,
|
|
675
858
|
dirtyFingerprint,
|
|
859
|
+
executionRoot,
|
|
676
860
|
exitCode: null,
|
|
677
861
|
signal: result.signal,
|
|
678
862
|
stdoutTail: '',
|
|
@@ -682,8 +866,51 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
682
866
|
},
|
|
683
867
|
};
|
|
684
868
|
}
|
|
869
|
+
// gh#1397: CLEO wrapped this spawn itself (T12116), so a failure of
|
|
870
|
+
// the WRAPPER is CLEO's own fault and not a verdict on the project's
|
|
871
|
+
// tool. Checked before the cache entry is built because, like the
|
|
872
|
+
// `timedOut` branch above, this run produced no result to cache — and
|
|
873
|
+
// caching one would serve a fabricated "your tests failed" from a
|
|
874
|
+
// key that cannot rotate until the tree changes.
|
|
875
|
+
// `limited.confined` OR the project's own pinned wrapper: post-detection
|
|
876
|
+
// (gh#1396) CLEO declines to wrap a command that is already
|
|
877
|
+
// `systemd-run`, so `confined` is false for exactly the project whose
|
|
878
|
+
// harness failures prompted gh#1397.
|
|
879
|
+
const harnessFailure = confinementStartupFailure(result.stderr, limited.confined || isSystemdRunCommand(command.cmd));
|
|
880
|
+
if (harnessFailure !== null) {
|
|
881
|
+
return {
|
|
882
|
+
exitCode: result.exitCode,
|
|
883
|
+
signal: result.signal,
|
|
884
|
+
stdoutTail: tailString(result.stdout, tailBytes),
|
|
885
|
+
stderrTail: tailString(result.stderr, tailBytes),
|
|
886
|
+
durationMs,
|
|
887
|
+
cacheHit: false,
|
|
888
|
+
timedOut: false,
|
|
889
|
+
lockBusy: false,
|
|
890
|
+
executionRoot,
|
|
891
|
+
harnessFailure,
|
|
892
|
+
entry: {
|
|
893
|
+
schemaVersion: 2,
|
|
894
|
+
key,
|
|
895
|
+
canonical: command.canonical,
|
|
896
|
+
displayName: command.displayName,
|
|
897
|
+
cmd: command.cmd,
|
|
898
|
+
args: command.args,
|
|
899
|
+
source: command.source,
|
|
900
|
+
head,
|
|
901
|
+
dirtyFingerprint,
|
|
902
|
+
executionRoot,
|
|
903
|
+
exitCode: null,
|
|
904
|
+
signal: result.signal,
|
|
905
|
+
stdoutTail: '',
|
|
906
|
+
stderrTail: tailString(result.stderr, tailBytes),
|
|
907
|
+
durationMs,
|
|
908
|
+
capturedAt: new Date().toISOString(),
|
|
909
|
+
},
|
|
910
|
+
};
|
|
911
|
+
}
|
|
685
912
|
const entry = {
|
|
686
|
-
schemaVersion:
|
|
913
|
+
schemaVersion: 2,
|
|
687
914
|
key,
|
|
688
915
|
canonical: command.canonical,
|
|
689
916
|
displayName: command.displayName,
|
|
@@ -692,6 +919,7 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
692
919
|
source: command.source,
|
|
693
920
|
head,
|
|
694
921
|
dirtyFingerprint,
|
|
922
|
+
executionRoot,
|
|
695
923
|
exitCode: result.exitCode,
|
|
696
924
|
signal: result.signal,
|
|
697
925
|
stdoutTail: tailString(result.stdout, tailBytes),
|
|
@@ -699,27 +927,32 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
699
927
|
durationMs,
|
|
700
928
|
capturedAt: new Date().toISOString(),
|
|
701
929
|
};
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
// through to here.
|
|
709
|
-
// gh#1380: `exitCode === null` means we do not know what happened.
|
|
710
|
-
// gh#1404: `head === null` means the key cannot rotate, so whatever is
|
|
711
|
-
// stored becomes permanent regardless of any later source change.
|
|
930
|
+
// Persist only a usable entry. `isEntryUsable` is the SAME predicate
|
|
931
|
+
// `readCacheEntry` applies, which is the point: these two guards used
|
|
932
|
+
// to be independent transcriptions of one rule — `exitCode !== null
|
|
933
|
+
// && head !== null`, written out twice — so a field added to one had
|
|
934
|
+
// to be remembered into the other. gh#1380 and gh#1404 each edited
|
|
935
|
+
// both, and nothing checked that they still agreed.
|
|
712
936
|
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
937
|
+
// What the shared predicate refuses, and why none of it is an
|
|
938
|
+
// overcorrection:
|
|
939
|
+
// - unknown outcome (`exitCode: null`) — a signal kill, or a
|
|
940
|
+
// failure to start. The `timedOut` branch above already returns
|
|
941
|
+
// without writing for this reason; an OOM kill that is not a CLEO
|
|
942
|
+
// timeout (gh#1381) had no equivalent guard and fell through here.
|
|
943
|
+
// - unrotatable key (`head: null`) — costs a non-git CLEO root all
|
|
944
|
+
// caching. Stated rather than hidden: those projects are not
|
|
945
|
+
// getting valid caching today, they are getting ONE answer
|
|
946
|
+
// forever, and a slow correct answer beats a fast fabricated one.
|
|
947
|
+
// - unattributable run (`executionRoot` absent) — cannot occur on
|
|
948
|
+
// this path, and is required here anyway so that read and write
|
|
949
|
+
// stay ONE rule rather than two that happen to match today.
|
|
718
950
|
//
|
|
719
|
-
// Deliberately narrow
|
|
720
|
-
// would trade a fabricated pass for a permanent cache
|
|
721
|
-
// projects — the same overcorrection pointed the
|
|
722
|
-
|
|
951
|
+
// Deliberately narrow in the other direction too: refusing, say, all
|
|
952
|
+
// non-zero exits would trade a fabricated pass for a permanent cache
|
|
953
|
+
// miss on healthy projects — the same overcorrection pointed the
|
|
954
|
+
// other way.
|
|
955
|
+
if (isEntryUsable(entry)) {
|
|
723
956
|
writeCacheEntry(projectRoot, entry);
|
|
724
957
|
}
|
|
725
958
|
return {
|
|
@@ -732,6 +965,7 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
732
965
|
timedOut: false,
|
|
733
966
|
lockBusy: false,
|
|
734
967
|
executionRoot,
|
|
968
|
+
harnessFailure: null,
|
|
735
969
|
entry,
|
|
736
970
|
};
|
|
737
971
|
}, { stale: lockStaleMs, retries: 3 });
|
|
@@ -755,9 +989,10 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
755
989
|
cacheHit: false,
|
|
756
990
|
timedOut: false,
|
|
757
991
|
lockBusy: true,
|
|
992
|
+
harnessFailure: null,
|
|
758
993
|
executionRoot,
|
|
759
994
|
entry: {
|
|
760
|
-
schemaVersion:
|
|
995
|
+
schemaVersion: 2,
|
|
761
996
|
key,
|
|
762
997
|
canonical: command.canonical,
|
|
763
998
|
displayName: command.displayName,
|
|
@@ -766,6 +1001,7 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
766
1001
|
source: command.source,
|
|
767
1002
|
head,
|
|
768
1003
|
dirtyFingerprint,
|
|
1004
|
+
executionRoot,
|
|
769
1005
|
exitCode: null,
|
|
770
1006
|
stdoutTail: '',
|
|
771
1007
|
stderrTail: '',
|