@vzn/vx 0.0.630 → 0.0.632

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.
Files changed (62) hide show
  1. package/README.md +7 -8
  2. package/package.json +7 -5
  3. package/schemas/cache.json +24 -0
  4. package/schemas/error.json +23 -0
  5. package/schemas/info.json +147 -0
  6. package/schemas/last.json +188 -0
  7. package/schemas/plan.json +88 -0
  8. package/schemas/show.json +124 -0
  9. package/schemas/summary.json +133 -0
  10. package/schemas/why.json +168 -0
  11. package/skills/vx/SKILL.md +85 -0
  12. package/src/bin.ts +9 -0
  13. package/src/cache/archive.ts +153 -51
  14. package/src/cache/cache.ts +45 -19
  15. package/src/cache/layer.ts +2 -1
  16. package/src/cache/schema.ts +2 -1
  17. package/src/cache/tar-stream.ts +204 -66
  18. package/src/cache/zstd.ts +1 -1
  19. package/src/cli/cache.ts +14 -14
  20. package/src/cli/foreign-flags.ts +1 -1
  21. package/src/cli/help.ts +30 -5
  22. package/src/cli/index.ts +23 -11
  23. package/src/cli/info.ts +38 -9
  24. package/src/cli/init.ts +97 -19
  25. package/src/cli/last.ts +54 -3
  26. package/src/cli/plan-format.ts +23 -1
  27. package/src/cli/plugin-commands.ts +3 -0
  28. package/src/cli/plugin-templates.ts +1 -1
  29. package/src/cli/run.ts +40 -21
  30. package/src/cli/show.ts +10 -7
  31. package/src/cli/upgrade.ts +1 -1
  32. package/src/cli/watch.ts +7 -2
  33. package/src/cli/why.ts +14 -40
  34. package/src/exec/runner.ts +27 -10
  35. package/src/graph/scheduler.ts +36 -0
  36. package/src/graph/task-graph.ts +1 -1
  37. package/src/index.ts +22 -5
  38. package/src/orchestrator/admission.ts +29 -1
  39. package/src/orchestrator/affected-tasks.ts +87 -18
  40. package/src/orchestrator/doctor.ts +14 -1
  41. package/src/orchestrator/execute-task.ts +149 -64
  42. package/src/orchestrator/failure-mode.ts +5 -3
  43. package/src/orchestrator/framed-output.ts +17 -3
  44. package/src/orchestrator/index.ts +11 -0
  45. package/src/orchestrator/logger.ts +57 -6
  46. package/src/orchestrator/metrics.ts +65 -0
  47. package/src/orchestrator/options.ts +12 -0
  48. package/src/orchestrator/output-log.ts +14 -1
  49. package/src/orchestrator/path-links.ts +79 -0
  50. package/src/orchestrator/plan.ts +20 -3
  51. package/src/orchestrator/plugin.ts +7 -0
  52. package/src/orchestrator/prepare.ts +14 -4
  53. package/src/orchestrator/run-artifacts.ts +18 -2
  54. package/src/orchestrator/run-failures.ts +192 -0
  55. package/src/orchestrator/run-records.ts +10 -8
  56. package/src/orchestrator/run.ts +87 -22
  57. package/src/orchestrator/terminal-signals.ts +16 -0
  58. package/src/orchestrator/upstream.ts +65 -10
  59. package/src/util/errors.ts +55 -1
  60. package/src/util/index.ts +3 -0
  61. package/src/util/secret-mask.ts +13 -1
  62. package/src/workspace/workspace.ts +1 -0
@@ -50,7 +50,15 @@ import { lstat, mkdir, readlink, realpath, rmdir, stat, unlink, writeFile } from
50
50
  import path from 'node:path'
51
51
  import { UserError } from '../util/index.js'
52
52
  import { WORKSPACE_OUTPUT_PREFIX } from './layer.js'
53
- import { TarFormatError, type TarInput, tarEntries, tarPack, tarSize } from './tar-stream.js'
53
+ import {
54
+ TarFormatError,
55
+ type TarInput,
56
+ tarEntries,
57
+ tarEntriesSync,
58
+ tarPack,
59
+ tarPackInto,
60
+ tarSize,
61
+ } from './tar-stream.js'
54
62
  import { ON_THREAD_MAX } from './zstd.js'
55
63
 
56
64
  /** Archive entry name carrying the per-output mode/mtime sidecar. */
@@ -59,6 +67,10 @@ const META_ENTRY = '.vx-meta.json'
59
67
  /** Archive entry name of the always-present stdout record. */
60
68
  const STDOUT_ENTRY = 'stdout'
61
69
 
70
+ // One decoder, its defaults (a leading BOM dropped), for both scanners.
71
+ // `ignoreBOM`: a leading U+FEFF in a task's stdout is the task's.
72
+ const utf8 = new TextDecoder('utf-8', { ignoreBOM: true })
73
+
62
74
  /**
63
75
  * The last entry: a CRC-32 over every entry before it, each its name, a
64
76
  * NUL, then its body, as 8 lowercase hex digits (v36, L-19). Nothing else
@@ -107,19 +119,30 @@ class SumCheck {
107
119
  name: string,
108
120
  body: AsyncIterable<Uint8Array>,
109
121
  ): Promise<AsyncIterable<Uint8Array> | null> {
122
+ if (!this.enter(name)) return this.sum.through(body)
123
+ this.verify(await textOf(body))
124
+ return null
125
+ }
126
+ /**
127
+ * The next regular entry: true when it is the sum entry, whose text the
128
+ * caller hands to `verify`; otherwise the caller hands its bytes to `add`.
129
+ */
130
+ enter(name: string): boolean {
110
131
  if (this.seen) throw new TarFormatError(`entry ${name} follows the artifact's checksum`)
111
- if (name === SUM_ENTRY) {
112
- const got = await textOf(body)
113
- if (got !== this.sum.hex()) {
114
- throw new TarFormatError(
115
- `artifact content does not match its checksum (${got}, read ${this.sum.hex()})`,
116
- )
117
- }
118
- this.seen = true
119
- return null
120
- }
132
+ if (name === SUM_ENTRY) return true
121
133
  this.sum.entry(name)
122
- return this.sum.through(body)
134
+ return false
135
+ }
136
+ add(bytes: Uint8Array): void {
137
+ this.sum.add(bytes)
138
+ }
139
+ verify(got: string): void {
140
+ if (got !== this.sum.hex()) {
141
+ throw new TarFormatError(
142
+ `artifact content does not match its checksum (${got}, read ${this.sum.hex()})`,
143
+ )
144
+ }
145
+ this.seen = true
123
146
  }
124
147
  done(): void {
125
148
  if (!this.seen) throw new TarFormatError('artifact carries no checksum')
@@ -373,16 +396,12 @@ async function* summed(inputs: readonly TarInput[]): AsyncGenerator<TarInput> {
373
396
  * otherwise (measured 2026-09-03: 238 → 293 ms per 1 000 saves).
374
397
  */
375
398
  export async function packArtifactBytes(plan: ArtifactPlan): Promise<Uint8Array> {
376
- // A small artifact's files are read on this thread (`ON_THREAD_MAX`).
377
- const onThread = plan.size <= ON_THREAD_MAX
399
+ // A small artifact is read and packed on this thread (`ON_THREAD_MAX`):
400
+ // no promise or generator hop per file or block.
401
+ if (plan.size <= ON_THREAD_MAX) return packSmall(plan)
378
402
  const inputs = await Promise.all(
379
403
  plan.inputs.map(async (i) =>
380
- i.body instanceof Blob
381
- ? {
382
- ...i,
383
- body: onThread ? readFileSync((i.body as Bun.BunFile).name!) : await i.body.bytes(),
384
- }
385
- : i,
404
+ i.body instanceof Blob ? { ...i, body: await i.body.bytes() } : i,
386
405
  ),
387
406
  )
388
407
  const out = new Uint8Array(plan.size)
@@ -395,6 +414,28 @@ export async function packArtifactBytes(plan: ArtifactPlan): Promise<Uint8Array>
395
414
  return out
396
415
  }
397
416
 
417
+ /** `summed` + `tarPack` for a plan whose bodies fit in memory, synchronously. */
418
+ function packSmall(plan: ArtifactPlan): Uint8Array {
419
+ const sum = new EntrySum()
420
+ const inputs: (TarInput & { body: Uint8Array | string })[] = []
421
+ for (const i of plan.inputs) {
422
+ const body =
423
+ i.body instanceof Blob
424
+ ? readFileSync((i.body as Bun.BunFile).name!)
425
+ : typeof i.body === 'string'
426
+ ? new TextEncoder().encode(i.body)
427
+ : (i.body as Uint8Array)
428
+ sum.entry(i.name)
429
+ sum.add(body)
430
+ inputs.push({ ...i, body })
431
+ }
432
+ inputs.push({ name: SUM_ENTRY, size: SUM_SIZE, body: sum.hex() })
433
+ const out = new Uint8Array(plan.size)
434
+ const n = tarPackInto(inputs, out)
435
+ if (n !== plan.size) throw new TarFormatError(`packed ${n} bytes, planned ${plan.size}`)
436
+ return out
437
+ }
438
+
398
439
  /** Plan and pack in one go, in memory. */
399
440
  export async function packArtifact(args: PackArgs): Promise<Uint8Array> {
400
441
  return await packArtifactBytes(await planArtifact(args))
@@ -413,6 +454,51 @@ function streamOf(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
413
454
  })
414
455
  }
415
456
 
457
+ /** What a scan of an artifact reads out of it. */
458
+ interface ArtifactScan {
459
+ entries: ArchiveEntry[]
460
+ stdout: string | null
461
+ exec: ExecUsage | undefined
462
+ key: string | undefined
463
+ }
464
+
465
+ /** A scan's result, built entry by entry in tar order: the one fold both scanners share. */
466
+ class ScanFold {
467
+ private readonly seen: Array<{ name: string; size: number; mtimeMs: number }> = []
468
+ private files: MetaFile['files'] = {}
469
+ private exec: ExecUsage | undefined
470
+ private key: string | undefined
471
+ stdout: string | null = null
472
+
473
+ sidecar(text: string): void {
474
+ const parsed = JSON.parse(text) as MetaFile
475
+ this.files = parsed.files ?? {}
476
+ // The ingest side is the untrusted boundary: a foreign sidecar's
477
+ // usage is taken only when it is a plain non-negative number.
478
+ this.exec = usageOf(parsed.exec)
479
+ if (typeof parsed.key === 'string') this.key = parsed.key
480
+ }
481
+
482
+ entry(name: string, size: number, mtimeMs: number): void {
483
+ this.seen.push({ name, size, mtimeMs })
484
+ }
485
+
486
+ result(): ArtifactScan {
487
+ return {
488
+ entries: this.seen.map((s) => {
489
+ const m = this.files[s.name]
490
+ // A foreign artifact (or one whose sidecar lost an entry) restores
491
+ // readable-but-not-executable rather than failing the hit: the
492
+ // sidecar is metadata, never the authority on whether bytes exist.
493
+ return { name: s.name, size: s.size, mode: m?.[0] ?? 0o644, mtimeMs: m?.[1] ?? s.mtimeMs }
494
+ }),
495
+ stdout: this.stdout,
496
+ exec: this.exec,
497
+ key: this.key,
498
+ }
499
+ }
500
+ }
501
+
416
502
  /**
417
503
  * List an artifact's regular entries with their restore metadata, and
418
504
  * its stdout text (`null` when the entry is absent — not a vx artifact),
@@ -420,49 +506,55 @@ function streamOf(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
420
506
  * name is unsafe — a partial reading of a poisoned artifact is the
421
507
  * outcome the traversal defense exists to prevent.
422
508
  */
423
- export async function scanArtifact(tar: ReadableStream<Uint8Array>): Promise<{
424
- entries: ArchiveEntry[]
425
- stdout: string | null
426
- exec: ExecUsage | undefined
427
- key: string | undefined
428
- }> {
429
- const seen: Array<{ name: string; size: number; mtimeMs: number }> = []
430
- let meta: MetaFile['files'] = {}
431
- let exec: ExecUsage | undefined
432
- let key: string | undefined
433
- let stdout: string | null = null
509
+ export async function scanArtifact(tar: ReadableStream<Uint8Array>): Promise<ArtifactScan> {
510
+ const fold = new ScanFold()
434
511
  const check = new SumCheck()
435
512
  for await (const e of tarEntries(tar)) {
436
513
  if (e.type !== '0') continue
437
514
  const body = await check.body(e.name, e.body)
438
515
  if (body === null) continue
439
516
  if (e.name === META_ENTRY) {
440
- const parsed = JSON.parse(await textOf(body)) as MetaFile
441
- meta = parsed.files ?? {}
442
- // The ingest side is the untrusted boundary: a foreign sidecar's
443
- // usage is taken only when it is a plain non-negative number.
444
- exec = usageOf(parsed.exec)
445
- if (typeof parsed.key === 'string') key = parsed.key
517
+ fold.sidecar(await textOf(body))
446
518
  continue
447
519
  }
448
520
  assertSafeName(e.name)
449
- if (e.name === STDOUT_ENTRY) stdout = await textOf(body)
521
+ if (e.name === STDOUT_ENTRY) fold.stdout = await textOf(body)
450
522
  else await drain(body)
451
- seen.push({ name: e.name, size: e.size, mtimeMs: e.mtimeMs })
523
+ fold.entry(e.name, e.size, e.mtimeMs)
452
524
  }
453
525
  check.done()
454
- return {
455
- entries: seen.map((s) => {
456
- const m = meta[s.name]
457
- // A foreign artifact (or one whose sidecar lost an entry) restores
458
- // readable-but-not-executable rather than failing the hit: the
459
- // sidecar is metadata, never the authority on whether bytes exist.
460
- return { name: s.name, size: s.size, mode: m?.[0] ?? 0o644, mtimeMs: m?.[1] ?? s.mtimeMs }
461
- }),
462
- stdout,
463
- exec,
464
- key,
526
+ return fold.result()
527
+ }
528
+
529
+ /**
530
+ * `scanArtifact` over a tar in memory, synchronously: a save scans the
531
+ * tar it just packed. Same decoder, same checks in the same order, same
532
+ * result. The async generators, per-chunk promises and drain were most
533
+ * of a small save's scan: one 400 B output, 49 → 14 µs (X-191).
534
+ */
535
+ export function scanTarBytes(tar: Uint8Array): ArtifactScan {
536
+ const fold = new ScanFold()
537
+ const check = new SumCheck()
538
+ for (const e of tarEntriesSync(tar)) {
539
+ if (e.type !== '0') continue
540
+ if (check.enter(e.name)) {
541
+ check.verify(utf8.decode(e.body()))
542
+ continue
543
+ }
544
+ if (e.name === META_ENTRY) {
545
+ const body = e.body()
546
+ check.add(body)
547
+ fold.sidecar(utf8.decode(body))
548
+ continue
549
+ }
550
+ assertSafeName(e.name)
551
+ const body = e.body()
552
+ check.add(body)
553
+ if (e.name === STDOUT_ENTRY) fold.stdout = utf8.decode(body)
554
+ fold.entry(e.name, e.size, e.mtimeMs)
465
555
  }
556
+ check.done()
557
+ return fold.result()
466
558
  }
467
559
 
468
560
  /** The usage worth carrying: each axis a finite non-negative number, or absent; nothing → undefined. */
@@ -569,7 +661,7 @@ async function bytesOf(body: AsyncIterable<Uint8Array>): Promise<Uint8Array> {
569
661
  }
570
662
 
571
663
  const textOf = async (body: AsyncIterable<Uint8Array>): Promise<string> =>
572
- new TextDecoder().decode(await bytesOf(body))
664
+ utf8.decode(await bytesOf(body))
573
665
 
574
666
  /** One staged entry: written at `tmp`, renamed to `target` on commit. */
575
667
  interface Staged {
@@ -1012,6 +1104,12 @@ function assertSafeName(name: string): void {
1012
1104
  if (name.includes('//')) {
1013
1105
  throw new ArchiveSecurityError(`archive entry name has empty path component (unsafe): ${name}`)
1014
1106
  }
1107
+ // vx never packs a `.` segment, and `outputs/.` names the anchor itself:
1108
+ // ingest kept it, and every hit then failed renaming a file over the
1109
+ // project directory, blamed on the user's tree.
1110
+ if (hasDotSegment(name)) {
1111
+ throw new ArchiveSecurityError(`archive entry name has a '.' segment (unsafe): ${name}`)
1112
+ }
1015
1113
  // No backslash refusal: vx runs on Linux and macOS (Windows through WSL),
1016
1114
  // where it is a name character, and the save scans its own artifact with
1017
1115
  // these checks, so refusing one made `dist/back\slash` uncacheable.
@@ -1049,6 +1147,10 @@ const NAME_MAX = 255
1049
1147
  /** Bytes a path may hold, its terminating NUL included (limits.h). */
1050
1148
  const PATH_MAX = process.platform === 'darwin' ? 1024 : 4096
1051
1149
 
1150
+ function hasDotSegment(p: string): boolean {
1151
+ return p === '.' || p.startsWith('./') || p.endsWith('/.') || p.includes('/./')
1152
+ }
1153
+
1052
1154
  function hasParentSegment(p: string): boolean {
1053
1155
  if (p === '..' || p.startsWith('../') || p.endsWith('/..')) return true
1054
1156
  return p.includes('/../')
@@ -70,6 +70,7 @@ import {
70
70
  type ExecUsage,
71
71
  extractArtifactStream,
72
72
  scanArtifact,
73
+ scanTarBytes,
73
74
  packArtifactBytes,
74
75
  packArtifactStream,
75
76
  planArtifact,
@@ -100,7 +101,6 @@ import {
100
101
  bytesOf,
101
102
  decodedTar,
102
103
  MAX_DECOMPRESSED_ARTIFACT_BYTES,
103
- oneChunk,
104
104
  STREAM_DECODE_FROM,
105
105
  zstdCompress,
106
106
  zstdEncoder,
@@ -122,6 +122,16 @@ import { CACHE_VERSION, foldKey } from './key-fold.js'
122
122
  */
123
123
  const ORPHAN_GRACE_MS = 60 * 60 * 1000
124
124
 
125
+ /**
126
+ * A hit renews an entry's `accessed_at` only once it is older than this:
127
+ * renewing every hit rewrote 2,180 rows at a warm 1,090-package run's
128
+ * close, ~30 ms of it. So `accessed_at` trails the last use by at most
129
+ * this much, and the retention policy reads it plus this as the last use:
130
+ * an entry used within the limit is never pruned, and one may stay up to
131
+ * this long past it (owner, 2026-10-08).
132
+ */
133
+ const ACCESS_REFRESH_MS = 60 * 60 * 1000
134
+
125
135
  /**
126
136
  * The only names the orphan sweep may unlink: an artifact under a key
127
137
  * `foldKey` prints (16 lowercase hex) and the temp `tempPath` makes of
@@ -298,11 +308,12 @@ function pickVictims(
298
308
  const kept: Array<{ at: number; size: number; row?: string; file?: RowlessFile }> = []
299
309
  let remaining = 0
300
310
  for (const r of rows) {
301
- if (olderThanMs !== undefined && r.accessed_at < olderThanMs) {
311
+ const at = r.accessed_at + ACCESS_REFRESH_MS
312
+ if (olderThanMs !== undefined && at < olderThanMs) {
302
313
  hashes.add(r.hash)
303
314
  bytesFreed += r.size_bytes
304
315
  } else {
305
- kept.push({ at: r.accessed_at, size: r.size_bytes, row: r.hash })
316
+ kept.push({ at, size: r.size_bytes, row: r.hash })
306
317
  remaining += r.size_bytes
307
318
  }
308
319
  }
@@ -536,6 +547,7 @@ function ensurePrivateDir(dir: string): string | null {
536
547
  // enumeration on the bench workspace before its generator ignored `.vx`. An
537
548
  // ignored directory is skipped by the walk entirely. Only written when
538
549
  // absent, so a user's own file wins.
550
+ const utf8 = new TextEncoder()
539
551
  const IGNORE_ALL = '*\n'
540
552
 
541
553
  /**
@@ -830,6 +842,7 @@ export class Cache implements CacheLayer {
830
842
  if (mode !== 'open') {
831
843
  throw new UserError(
832
844
  `the cache at ${cacheDir} holds index schema ${found} from another vx version; this vx reads ${SCHEMA_VERSION}, so nothing in it is readable here, and this verb leaves it untouched. The next \`vx run\` resets it`,
845
+ 'VX_E_CACHE_VERSION',
833
846
  )
834
847
  }
835
848
  }
@@ -966,7 +979,7 @@ export class Cache implements CacheLayer {
966
979
  )
967
980
  this.upsertStdout = lazyStatement(
968
981
  this.db,
969
- 'INSERT INTO entry_stdout(hash, stdout) VALUES (?, ?) ON CONFLICT(hash) DO UPDATE SET stdout = excluded.stdout',
982
+ 'INSERT INTO entry_stdout(hash, stdout) VALUES (?, CAST(? AS TEXT)) ON CONFLICT(hash) DO UPDATE SET stdout = excluded.stdout',
970
983
  )
971
984
  this.deleteStdout = lazyStatement(this.db, 'DELETE FROM entry_stdout WHERE hash = ?')
972
985
  this.selectEntry = lazyStatement(this.db, `${SELECT_ENTRY} WHERE e.hash = ?`)
@@ -1347,7 +1360,7 @@ export class Cache implements CacheLayer {
1347
1360
  // full-cache run. Hashes are collected and flushed as ONE batched
1348
1361
  // UPDATE by flushAccessed() (called from prune/stats/close), which
1349
1362
  // is when accessed_at is actually read.
1350
- this.touched.add(hash)
1363
+ if (row.accessed_at < Date.now() - ACCESS_REFRESH_MS) this.touched.add(hash)
1351
1364
 
1352
1365
  // Pure SQL: outputFiles come from the output_files rows and
1353
1366
  // stdout from the entries row. The artifact is NOT touched here —
@@ -1392,6 +1405,7 @@ export class Cache implements CacheLayer {
1392
1405
  const rows = this.db.query(`${SELECT_ENTRY} WHERE e.hash ${test}`).all(...params) as EntryRow[]
1393
1406
  if (rows.length === 0) return out
1394
1407
  const graceStart = Date.now() - ORPHAN_GRACE_MS
1408
+ const refreshStart = Date.now() - ACCESS_REFRESH_MS
1395
1409
  const live = rows.filter((r) => {
1396
1410
  const st = statSync(this.tarPath(r.hash), { throwIfNoEntry: false })
1397
1411
  if (st !== undefined && st.mtimeMs < graceStart) this.staleTimes.add(r.hash)
@@ -1402,7 +1416,7 @@ export class Cache implements CacheLayer {
1402
1416
  this.outputs.hold(fileRows)
1403
1417
  const dirRows = this.loadOutputDirsBatch(liveHashes)
1404
1418
  for (const row of live) {
1405
- this.touched.add(row.hash)
1419
+ if (row.accessed_at < refreshStart) this.touched.add(row.hash)
1406
1420
  const entry = entryOf(row, fileRows.get(row.hash) ?? [])
1407
1421
  entry.outputDirRows = dirRows.get(row.hash) ?? []
1408
1422
  out.set(row.hash, entry)
@@ -1990,17 +2004,20 @@ export class Cache implements CacheLayer {
1990
2004
  }
1991
2005
  let scanned: Awaited<ReturnType<typeof scanArtifact>>
1992
2006
  try {
1993
- // ingest() is the UNTRUSTED boundary — its temp holds bytes just
1994
- // pulled from a remote. Refuse a bomb (declared or sizeless) before it
1995
- // can expand into memory.
1996
- const source =
1997
- compressed instanceof Uint8Array && compressed.byteLength <= STREAM_DECODE_FROM
1998
- ? compressed
1999
- : Bun.file(tmpPath)
2000
2007
  const endScan = span('save: scan')
2001
- scanned = await scanArtifact(
2002
- tar !== undefined ? oneChunk(tar) : await decodedTar(source, hash, this.artifactCeiling),
2003
- )
2008
+ if (tar !== undefined) {
2009
+ // Our own tar, in memory: read in place, on this thread.
2010
+ scanned = scanTarBytes(tar)
2011
+ } else {
2012
+ // ingest() is the UNTRUSTED boundary — its temp holds bytes just
2013
+ // pulled from a remote. Refuse a bomb (declared or sizeless) before it
2014
+ // can expand into memory.
2015
+ const source =
2016
+ compressed instanceof Uint8Array && compressed.byteLength <= STREAM_DECODE_FROM
2017
+ ? compressed
2018
+ : Bun.file(tmpPath)
2019
+ scanned = await scanArtifact(await decodedTar(source, hash, this.artifactCeiling))
2020
+ }
2004
2021
  endScan()
2005
2022
  // v17 invariant: every artifact carries a `stdout` entry. Its
2006
2023
  // absence means the bytes decompressed but aren't a vx artifact.
@@ -2118,7 +2135,13 @@ export class Cache implements CacheLayer {
2118
2135
  scanned.exec?.peakRssBytes ?? null,
2119
2136
  )
2120
2137
  if (stdoutText === '') deleteStdout.run(hash)
2121
- else upsertStdout.run(hash, stdoutText)
2138
+ // bun:sqlite drops a leading U+FEFF from a bound string; its UTF-8
2139
+ // bytes, cast to TEXT, keep it.
2140
+ else
2141
+ upsertStdout.run(
2142
+ hash,
2143
+ stdoutText.startsWith('\uFEFF') ? utf8.encode(stdoutText) : stdoutText,
2144
+ )
2122
2145
  outputs.replaceFileRows(hash, outputFileRows)
2123
2146
  // INSERT OR IGNORE: identical inputs derive this same hash, so a
2124
2147
  // re-save's rows are identical — keep the first set, skip the rest.
@@ -2143,7 +2166,8 @@ export class Cache implements CacheLayer {
2143
2166
 
2144
2167
  /** Apply the deferred accessed_at bumps in one statement. */
2145
2168
  private flushAccessed(): void {
2146
- if (this.touched.size === 0) return
2169
+ // Apart: a hit whose row is fresh still renews an old file time.
2170
+ if (this.touched.size === 0 && this.staleTimes.size === 0) return
2147
2171
  const hashes = [...this.touched]
2148
2172
  this.touched.clear()
2149
2173
  const now = Date.now()
@@ -2158,6 +2182,7 @@ export class Cache implements CacheLayer {
2158
2182
  }
2159
2183
  }
2160
2184
  this.staleTimes.clear()
2185
+ if (hashes.length === 0) return
2161
2186
  // LRU bookkeeping: on a full disk the bumps are dropped, never the prune
2162
2187
  // or the stats that asked for them (A-14).
2163
2188
  try {
@@ -2260,7 +2285,8 @@ export class Cache implements CacheLayer {
2260
2285
  'SELECT MIN(accessed_at) AS oldest, COALESCE(SUM(size_bytes), 0) AS bytes FROM entries',
2261
2286
  )
2262
2287
  .get() as { oldest: number | null; bytes: number }
2263
- const ageDue = olderThanMs !== undefined && oldest !== null && oldest < olderThanMs
2288
+ const ageDue =
2289
+ olderThanMs !== undefined && oldest !== null && oldest + ACCESS_REFRESH_MS < olderThanMs
2264
2290
  const sizeDue = policy.maxBytes !== undefined && bytes > policy.maxBytes
2265
2291
  if (ageDue || sizeDue) {
2266
2292
  return this.prune({
@@ -215,7 +215,8 @@ export interface RunRecord {
215
215
  /**
216
216
  * Why the task failed or was skipped, as the run's own footer said it
217
217
  * (v27 columns; NULL on older rows and where the reason does not apply):
218
- * a skip's root blocker, vx's own timeout, the sandbox's violation count,
218
+ * a skip's root blocker (or the one a task that ran behind a failure ran
219
+ * behind), vx's own timeout, the sandbox's violation count,
219
220
  * and why a persistent task never became ready.
220
221
  */
221
222
  blockedBy?: string
@@ -95,7 +95,8 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
95
95
  -- time by design; NULL on rows older than the column.
96
96
  cached INTEGER,
97
97
  -- v27: why a task failed or was skipped, as the run's own footer
98
- -- said it (items 267–270): a skip's root blocker (a task id), vx's
98
+ -- said it (items 267–270): a skip's root blocker (a task id), or
99
+ -- the one a task that ran behind a failure ran behind (X-183), vx's
99
100
  -- own timeout (1), the sandbox's violation count, and why a
100
101
  -- persistent task never became ready ('timeout' | 'exited' |
101
102
  -- 'spawn'). NULL where the reason does not apply.