@vzn/vx 0.0.21 → 0.0.23

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -1,6 +1,7 @@
1
1
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
2
2
  import { detectColors, type ColorSupport } from './colors.js'
3
3
  import {
4
+ formatFailureRecap,
4
5
  formatFrameClose,
5
6
  formatFrameOpen,
6
7
  formatPersistentTailBlock,
@@ -8,7 +9,15 @@ import {
8
9
  formatTaskExecutedLine,
9
10
  formatTaskHitLine,
10
11
  formatTaskSkippedLine,
12
+ type RecapEntry,
11
13
  } from './framed-output.js'
14
+ import {
15
+ appendRecapRing,
16
+ createRecapRing,
17
+ RECAP_TASKS,
18
+ recapTail,
19
+ type RecapRing,
20
+ } from './failure-recap.js'
12
21
  import {
13
22
  createOutputWriter,
14
23
  formatFailureLine,
@@ -20,7 +29,7 @@ import { formatDuration, formatSummarySection, type RunContext } from './summary
20
29
  import { isGroupTask } from '../graph/index.js'
21
30
  import { appendTail, createTail, resetTail, tailText, type Tail } from '../util/index.js'
22
31
  import { isCacheHit } from './telemetry.js'
23
- import { outcomeWord } from './events.js'
32
+ import { failedLabel, outcomeWord } from './events.js'
24
33
 
25
34
  export interface Logger {
26
35
  /** Header / footer / status text. Written verbatim, one trailing \n added. */
@@ -53,6 +62,21 @@ export interface Logger {
53
62
  runEnd?(): void
54
63
  }
55
64
 
65
+ /** The logger `run()` builds when none is passed, and the one that owns the recap. */
66
+ export interface DefaultLogger extends Logger {
67
+ /**
68
+ * The run's last block: each failed task's last lines (item 706). Empty
69
+ * when nothing failed, and in the modes that promise no task output.
70
+ */
71
+ failureRecap(): string[]
72
+ /**
73
+ * Hand over any output still coalesced, and write straight through from
74
+ * now on. `runEnd` does it; the run's own `finally` does it again for a
75
+ * throw that never reached `runEnd`.
76
+ */
77
+ settle(): void
78
+ }
79
+
56
80
  /**
57
81
  * The default logger's per-task output policy. Resolved once per run
58
82
  * from (in priority order) the explicit `--output-logs` override, a
@@ -152,8 +176,8 @@ export function defaultLogger(
152
176
  colors: ColorSupport = detectColors(),
153
177
  view: OutputView = { mode: 'full' },
154
178
  out: StatusStream = process.stdout,
155
- opts: { forceFloorMs?: number } = {},
156
- ): Logger {
179
+ opts: { forceFloorMs?: number; coalesce?: boolean } = {},
180
+ ): DefaultLogger {
157
181
  // Per-task buffers, split by stream. Splitting lets the framed-output
158
182
  // renderer put stdout under `├─ stdout` and stderr under `├─ stderr`.
159
183
  // The price: chunks that interleaved at runtime get re-ordered (all
@@ -215,6 +239,7 @@ export function defaultLogger(
215
239
  const writer = createOutputWriter(out, {
216
240
  enabled: view.ci !== true,
217
241
  ...(opts.forceFloorMs !== undefined ? { forceFloorMs: opts.forceFloorMs } : {}),
242
+ ...(opts.coalesce === true ? { coalesce: true } : {}),
218
243
  })
219
244
 
220
245
  // Status-display state, driven by the optional lifecycle hooks.
@@ -257,11 +282,12 @@ export function defaultLogger(
257
282
  let spreadMin = Infinity
258
283
  let spreadSum = 0
259
284
  let spreadCount = 0
260
- // Pinned zones above the worker rows: failures accumulate as they
261
- // happen; persistent tasks pin at ready (their outcome lands while
262
- // the child keeps running). Both live until runEnd kills the region.
263
- // Full failure frames, deferred to runEnd (owner: '✗ line, continue,
264
- // all full frames at the end'). 'full'/CI keeps frames inline.
285
+ // The pinned zone above the worker rows: persistent tasks pin at
286
+ // ready (their outcome lands while the child keeps running) and live
287
+ // until runEnd kills the region. A failure is not pinned: it is a
288
+ // permanent ◼ row the moment it happens, and its full frame is
289
+ // deferred to runEnd (owner: '✗ line, continue, all full frames at
290
+ // the end'). 'full'/CI keeps frames inline.
265
291
  const deferredFailures: string[] = []
266
292
  const pinnedPersistent: string[] = []
267
293
  let flushedFailures = false
@@ -287,6 +313,13 @@ export function defaultLogger(
287
313
  { node: TaskNode; outcome?: TaskOutcome; out: Tail; err: Tail }
288
314
  >()
289
315
  let flushedPersistent = false
316
+ // The recap's material, decided at each failure so nothing is held for a
317
+ // task that passed. A buffered task's tail is read off the buffers the
318
+ // frame drains; a live-streamed one keeps no buffer, so a bounded ring
319
+ // follows it from its frame-open until its outcome.
320
+ const recapEntries: RecapEntry[] = []
321
+ const recapMore: string[] = []
322
+ const liveRings = new Map<string, RecapRing>()
290
323
 
291
324
  const refresh = (force: boolean): void => {
292
325
  if (statusDead) return
@@ -372,6 +405,23 @@ export function defaultLogger(
372
405
  view.mode === 'focused' && isPrimary(node) && !isGroupTask(node) && requestedCount <= 1
373
406
 
374
407
  return {
408
+ settle() {
409
+ writer.settle()
410
+ },
411
+ failureRecap() {
412
+ if (recapEntries.length === 0) return []
413
+ // Fenced like a frame: a tail is the task's own text, and a line of it
414
+ // must not become a workflow command. Never inside a group, so it reads
415
+ // with every group collapsed.
416
+ const fence =
417
+ view.gha === true
418
+ ? (lines: string[]) =>
419
+ ghaFence(`${lines.join('\n')}\n`, ghaToken)
420
+ .slice(0, -1)
421
+ .split('\n')
422
+ : undefined
423
+ return formatFailureRecap(recapEntries, recapMore, colors, fence)
424
+ },
375
425
  status(line) {
376
426
  writer.write(`${line}\n`)
377
427
  },
@@ -404,6 +454,7 @@ export function defaultLogger(
404
454
  // Open the live frame: full task info even when the command
405
455
  // streams nothing (or the hit replays nothing).
406
456
  emitLine(formatFrameOpen(node, colors))
457
+ liveRings.set(node.id, createRecapRing())
407
458
  return
408
459
  }
409
460
  // Bound a persistent task's output from its FIRST chunk. Waiting for
@@ -452,13 +503,16 @@ export function defaultLogger(
452
503
  }
453
504
  }
454
505
  // Failures end the log: every deferred frame replays here, right
455
- // above the summary — the ✗ one-liners marked them in the
456
- // stream, the full diagnostics read last where eyes land.
506
+ // above the summary — the ◼ rows marked them in the stream,
507
+ // the full diagnostics read last where eyes land.
457
508
  // Guarded for repeat runEnd calls.
458
509
  if (!flushedFailures && deferredFailures.length > 0) {
459
510
  flushedFailures = true
460
511
  for (const block of deferredFailures) emitBlock(block)
461
512
  }
513
+ // The summary and the CLI's own lines follow on the stream directly:
514
+ // nothing may still be held, and nothing after this is.
515
+ writer.settle()
462
516
  },
463
517
  taskStdout(node, chunk) {
464
518
  if (discardsOutput) return
@@ -466,6 +520,8 @@ export function defaultLogger(
466
520
  streamedSinceBlock = true
467
521
  if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
468
522
  writer.write(chunk)
523
+ const ring = liveRings.get(node.id)
524
+ if (ring !== undefined) appendRecapRing(ring, chunk)
469
525
  return
470
526
  }
471
527
  const tail = persistentTails.get(node.id)
@@ -481,6 +537,8 @@ export function defaultLogger(
481
537
  streamedSinceBlock = true
482
538
  if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
483
539
  writer.write(chunk)
540
+ const ring = liveRings.get(node.id)
541
+ if (ring !== undefined) appendRecapRing(ring, chunk)
484
542
  return
485
543
  }
486
544
  const tail = persistentTails.get(node.id)
@@ -505,6 +563,28 @@ export function defaultLogger(
505
563
  // The pre-ready window closed either way: a failed task is over, and
506
564
  // a ready one starts a fresh post-ready tail below.
507
565
  persistentTails.delete(node.id)
566
+ const liveRing = liveRings.get(node.id)
567
+ liveRings.delete(node.id)
568
+ if (outcome.status === 'failed' && !discardsOutput) {
569
+ if (recapEntries.length === RECAP_TASKS) recapMore.push(node.id)
570
+ else {
571
+ let ring = liveRing
572
+ if (ring === undefined) {
573
+ ring = createRecapRing()
574
+ appendRecapRing(ring, stdout)
575
+ if (stdout.length > 0 && stderr.length > 0 && !stdout.endsWith('\n')) {
576
+ appendRecapRing(ring, '\n')
577
+ }
578
+ appendRecapRing(ring, stderr)
579
+ }
580
+ recapEntries.push({
581
+ node,
582
+ outcome,
583
+ tail: recapTail(ring),
584
+ droppedChars: (preReady?.out.dropped ?? 0) + (preReady?.err.dropped ?? 0),
585
+ })
586
+ }
587
+ }
508
588
  // An aborted task (child killed by a shutdown signal) reverts
509
589
  // to pending: free its worker slot, but never count or render it
510
590
  // — the run is tearing down and it has no honest outcome.
@@ -516,6 +596,22 @@ export function defaultLogger(
516
596
  if (qi >= 0) slotQueue.splice(qi, 1)
517
597
  }
518
598
  refresh(true)
599
+ // What it printed while it stopped (a trap's cleanup) still reaches
600
+ // the terminal where a frame would have: a task that traps Ctrl-C
601
+ // and exits 0 is aborted since item 962, and its frame was dropped
602
+ // with the count.
603
+ const framed = view.mode === 'full' || (view.mode === 'focused' && isPrimary(node))
604
+ if (framed && !isGroupTask(node)) {
605
+ if (streamsLive(node)) {
606
+ if (streamMidLine) {
607
+ writer.write('\n')
608
+ streamMidLine = false
609
+ }
610
+ emitFrameClose(formatFrameClose(node, outcome, colors))
611
+ } else if (stdout.length > 0 || stderr.length > 0) {
612
+ emitBlock(formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors, true))
613
+ }
614
+ }
519
615
  return
520
616
  }
521
617
  // Group tasks (no exec) do no work — no surface prints them.
@@ -595,7 +691,7 @@ export function defaultLogger(
595
691
  // their replay buffers are deliberately dropped; the counts
596
692
  // surface in the end-of-run summary.
597
693
  if (outcome.status === 'failed') {
598
- // ✗ marker now; the full frame replays at runEnd.
694
+ // ◼ row now; the full frame replays at runEnd.
599
695
  emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
600
696
  deferredFailures.push(
601
697
  formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
@@ -606,11 +702,11 @@ export function defaultLogger(
606
702
  return
607
703
  case 'focused':
608
704
  if (isPrimary(node)) {
609
- // Skipped tasks never started (upstream failed), so no
610
- // frame-open fired — and a skip has no output, so a
611
- // one-liner carries everything a frame would.
705
+ // Skipped tasks never started (blocked upstream, or the run
706
+ // stopped), so no frame-open fired — and a skip has no output,
707
+ // so a one-liner carries everything a frame would.
612
708
  if (outcome.status === 'skipped') {
613
- emitLine(formatTaskSkippedLine(node, colors))
709
+ emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
614
710
  return
615
711
  }
616
712
  if (streamsLive(node)) {
@@ -643,7 +739,7 @@ export function defaultLogger(
643
739
  return
644
740
  }
645
741
  // Dependency-pulled nodes: silent on success; failures get
646
- // the ✗ marker now and their frame replayed at runEnd.
742
+ // the ◼ row now and their frame replayed at runEnd.
647
743
  if (outcome.status === 'failed') {
648
744
  emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
649
745
  deferredFailures.push(
@@ -663,7 +759,7 @@ export function defaultLogger(
663
759
  return
664
760
  }
665
761
  if (outcome.status === 'skipped') {
666
- emitLine(formatTaskSkippedLine(node, colors))
762
+ emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
667
763
  return
668
764
  }
669
765
  const block = formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors)
@@ -676,10 +772,10 @@ export function defaultLogger(
676
772
  // Failed tasks stay pre-expanded in the Actions viewer:
677
773
  // an ::error annotation instead of a collapsed group.
678
774
  if (outcome.status === 'failed') {
679
- // The message is ours (a literal + an integer); only the title
680
- // carries user input.
775
+ // The message is ours (a literal, an integer and a signal
776
+ // name); only the title carries user input.
681
777
  emitBlock(
682
- `::error title=${ghaProperty(node.id)}::failed (exit ${outcome.exitCode})\n${body}`,
778
+ `::error title=${ghaProperty(node.id)}::${failedLabel(outcome.exitCode, outcome.timedOut, outcome.sandboxViolations, outcome.notReady)}\n${body}`,
683
779
  )
684
780
  } else {
685
781
  emitBlock(
@@ -15,6 +15,8 @@ import type { Database } from 'bun:sqlite'
15
15
  import { KEYED_RUNS_SQL } from '../cache/index.js'
16
16
  import { splitTaskId } from '../graph/index.js'
17
17
  import { clampInt } from '../util/index.js'
18
+ import type { TaskInputComponent } from './task-hash.js'
19
+ import { isPassStatus } from './telemetry.js'
18
20
 
19
21
  // ---------------------------------------------------------------------------
20
22
  // Run listing + detail
@@ -39,6 +41,11 @@ export interface RunSummaryRow {
39
41
  // (bigints aren't JSON-safe); use BigInt or Number on the client.
40
42
  wallclockStartNs: string | null
41
43
  wallclockEndNs: string | null
44
+ /** Why the task failed or was skipped (v27); null where the reason does not apply. */
45
+ blockedBy: string | null
46
+ timedOut: boolean | null
47
+ sandboxViolations: number | null
48
+ notReady: 'timeout' | 'exited' | 'spawn' | null
42
49
  }
43
50
 
44
51
  export interface ListRunsArgs {
@@ -69,12 +76,18 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
69
76
  const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
70
77
  type RawRow = Omit<
71
78
  RunSummaryRow,
72
- 'cacheHit' | 'cached' | 'wallclockStartNs' | 'wallclockEndNs'
79
+ 'cacheHit' | 'cached' | 'wallclockStartNs' | 'wallclockEndNs' | 'timedOut'
73
80
  > & {
74
81
  cacheHit: number | null
75
82
  cached: number | null
76
- wallclockStartNs: bigint | null
77
- wallclockEndNs: bigint | null
83
+ // NOT bigint: `bun:sqlite` hands these back as JS numbers (the handle
84
+ // does not set `safeIntegers`), so the annotation claimed a precision
85
+ // the read never had. Harmless in practice — these are ns RELATIVE to
86
+ // run t=0, and 2^53 ns is 104 days of run time — but a `bigint` here
87
+ // was simply false. `.toString()` reads the same on either.
88
+ wallclockStartNs: number | null
89
+ wallclockEndNs: number | null
90
+ timedOut: number | null
78
91
  }
79
92
  const rows = db
80
93
  .query(
@@ -82,14 +95,17 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
82
95
  duration_ms AS durationMs, started_at AS startedAt, ended_at AS endedAt,
83
96
  cache_hit AS cacheHit, cached, hash,
84
97
  cpu_ms AS cpuMs, peak_rss_bytes AS peakRssBytes,
85
- wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs
86
- FROM runs ${clause} ORDER BY started_at DESC LIMIT ?`,
98
+ wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs,
99
+ blocked_by AS blockedBy, timed_out AS timedOut,
100
+ sandbox_violations AS sandboxViolations, not_ready AS notReady
101
+ FROM runs ${clause} ORDER BY id DESC LIMIT ?`,
87
102
  )
88
103
  .all(...params, limit) as RawRow[]
89
104
  return rows.map((r) => ({
90
105
  ...r,
91
106
  cacheHit: r.cacheHit === null ? null : Boolean(r.cacheHit),
92
107
  cached: r.cached === null ? null : Boolean(r.cached),
108
+ timedOut: r.timedOut === null ? null : Boolean(r.timedOut),
93
109
  wallclockStartNs: r.wallclockStartNs === null ? null : r.wallclockStartNs.toString(),
94
110
  wallclockEndNs: r.wallclockEndNs === null ? null : r.wallclockEndNs.toString(),
95
111
  }))
@@ -264,7 +280,7 @@ export function listInvocations(
264
280
  const rows = db
265
281
  .query(
266
282
  `SELECT ${INVOCATION_COLUMNS} FROM invocations ${clause}
267
- ORDER BY started_at DESC LIMIT ?`,
283
+ ORDER BY rowid DESC LIMIT ?`,
268
284
  )
269
285
  .all(...params, limit) as InvocationRawRow[]
270
286
  return rows.map(mapInvocation)
@@ -370,25 +386,72 @@ export interface WhyDidThisRerun {
370
386
  note: string
371
387
  }
372
388
 
373
- /** The three endings an UNCHANGED cache key can have. */
374
- function unchangedKeyNote(cacheHit: number | null): string {
375
- if (cacheHit === null) {
389
+ /**
390
+ * The endings an UNCHANGED cache key can have. A re-execution is told apart
391
+ * by the evidence the index holds: every one used to blame `--no-cache` /
392
+ * `--force`, when the previous run on the key had failed (a failure saves
393
+ * nothing) or the entry had been pruned, and the invocation recorded that
394
+ * no flag was passed (item 1009).
395
+ */
396
+ function unchangedKeyNote(
397
+ db: Database,
398
+ runId: string,
399
+ this_: { hash: string; cacheHit: number | null; startedAt: number },
400
+ prev: { status: string },
401
+ ): string {
402
+ if (this_.cacheHit === null) {
376
403
  return 'cache key unchanged — this run recorded no cache outcome, so whether it re-ran is unknown'
377
404
  }
378
- return cacheHit
379
- ? 'cache key unchanged — this run was served from cache, nothing re-ran'
380
- : 'cache key unchanged — re-executed on the same key (--no-cache / --force, or unrelated)'
405
+ if (this_.cacheHit) return 'cache key unchanged — this run was served from cache, nothing re-ran'
406
+ if (prev.status === 'failed') {
407
+ return 'cache key unchanged — the previous run on this key failed and saved nothing, so there was nothing to hit'
408
+ }
409
+ const policy = (
410
+ db.query('SELECT cache_policy AS p FROM invocations WHERE run_id = ?').get(runId) as {
411
+ p: string
412
+ } | null
413
+ )?.p
414
+ if (policy !== undefined && !policy.split(',').some((axis) => axis === 'lR' || axis === 'rR')) {
415
+ return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache without read)'
416
+ }
417
+ // `get` answers null, not undefined, for no row.
418
+ const createdAt = (
419
+ db.query('SELECT created_at AS at FROM entries WHERE hash = ?').get(this_.hash) as {
420
+ at: number
421
+ } | null
422
+ )?.at
423
+ if (createdAt !== undefined && createdAt >= this_.startedAt) {
424
+ return 'cache key unchanged — no entry for this key was in the cache when it ran (pruned or evicted), so it executed and saved one'
425
+ }
426
+ return 'cache key unchanged — re-executed on the same key (--no-cache / --force, or unrelated)'
427
+ }
428
+
429
+ /**
430
+ * The latest recorded run of a task (run_id may be NULL on very old rows).
431
+ * One query for `vx why` and `vx mcp`: the CLI defaulted to it while the
432
+ * tool demanded a run id an agent had to fetch first (2026-09-16).
433
+ */
434
+ export function latestRunId(db: Database, taskId: string): string | null {
435
+ const [project, task] = splitTaskId(taskId)
436
+ const row = db
437
+ .query(
438
+ `SELECT run_id AS runId FROM runs WHERE project = ? AND task = ?
439
+ ORDER BY id DESC LIMIT 1`,
440
+ )
441
+ .get(project, task) as { runId: string | null } | undefined
442
+ return row?.runId ?? null
381
443
  }
382
444
 
383
445
  export function whyDidThisRerun(db: Database, runId: string, taskId: string): WhyDidThisRerun {
384
446
  const [project, task] = splitTaskId(taskId)
385
447
  const this_ = db
386
448
  .query(
387
- `SELECT hash, status, cache_hit AS cacheHit, cached, started_at AS startedAt
449
+ `SELECT id, hash, status, cache_hit AS cacheHit, cached, started_at AS startedAt
388
450
  FROM runs WHERE run_id = ? AND project = ? AND task = ?`,
389
451
  )
390
452
  .get(runId, project, task) as
391
453
  | {
454
+ id: number
392
455
  hash: string
393
456
  status: string
394
457
  cacheHit: number | null
@@ -411,10 +474,10 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
411
474
  const prev = db
412
475
  .query(
413
476
  `SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
414
- FROM runs WHERE project = ? AND task = ? AND started_at < ? AND ${KEYED_RUNS_SQL}
415
- ORDER BY started_at DESC LIMIT 1`,
477
+ FROM runs WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
478
+ ORDER BY id DESC LIMIT 1`,
416
479
  )
417
- .get(project, task, this_.startedAt) as
480
+ .get(project, task, this_.id) as
418
481
  | { hash: string; status: string; cacheHit: number | null; startedAt: number }
419
482
  | undefined
420
483
  // …and this run must have one too, or there is nothing to compare.
@@ -424,9 +487,11 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
424
487
  taskId,
425
488
  found: true,
426
489
  thisRun: {
427
- ...this_,
490
+ hash: this_.hash,
491
+ status: this_.status,
428
492
  cacheHit: this_.cacheHit === null ? null : Boolean(this_.cacheHit),
429
493
  cached: this_.cached === null ? null : Boolean(this_.cached),
494
+ startedAt: this_.startedAt,
430
495
  },
431
496
  previousRun: prev
432
497
  ? { ...prev, cacheHit: prev.cacheHit === null ? null : Boolean(prev.cacheHit) }
@@ -447,7 +512,7 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
447
512
  // have applied. Only a run that EXECUTED on an unchanged key is
448
513
  // the case this verb exists to explain. A row with no recorded
449
514
  // cacheHit (older rows) is neither — say that, do not guess.
450
- unchangedKeyNote(this_.cacheHit)
515
+ unchangedKeyNote(db, runId, this_, prev)
451
516
  : 'no prior run for this (project, task)',
452
517
  }
453
518
  }
@@ -478,17 +543,55 @@ export interface CacheKeyDiff {
478
543
  note: string
479
544
  }
480
545
 
481
- interface EntryInputRow {
482
- kind: string
483
- name: string
484
- hash: string
546
+ function loadEntryInputs(db: Database, entryHash: string): TaskInputComponent[] {
547
+ return db
548
+ .query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
549
+ .all(entryHash) as TaskInputComponent[]
485
550
  }
486
551
 
487
- function loadEntryInputs(db: Database, entryHash: string): Map<string, EntryInputRow> {
488
- const rows = db
489
- .query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
490
- .all(entryHash) as EntryInputRow[]
491
- return new Map(rows.map((r) => [`${r.kind}\0${r.name}`, r]))
552
+ /**
553
+ * The rule `vx why` and the site's playground both name a moved key by: join
554
+ * two keys' components over `(kind, name)`. A component in both with a
555
+ * different hash is `changed`, one only in `after` is `added`, one only in
556
+ * `before` is `removed`, and an equal one is counted. Entries are ordered by
557
+ * kind, then name.
558
+ */
559
+ export function diffKeyComponents(
560
+ before: readonly TaskInputComponent[],
561
+ after: readonly TaskInputComponent[],
562
+ ): { entries: InputDiffEntry[]; unchangedCount: number } {
563
+ const id = (c: TaskInputComponent): string => `${c.kind}\0${c.name}`
564
+ const cur = new Map(after.map((c) => [id(c), c]))
565
+ const old = new Map(before.map((c) => [id(c), c]))
566
+ const entries: InputDiffEntry[] = []
567
+ let unchangedCount = 0
568
+ const keys = new Set<string>([...cur.keys(), ...old.keys()])
569
+ for (const key of keys) {
570
+ const a = cur.get(key)
571
+ const b = old.get(key)
572
+ if (a && b) {
573
+ if (a.hash === b.hash) unchangedCount++
574
+ else
575
+ entries.push({
576
+ kind: a.kind,
577
+ name: a.name,
578
+ change: 'changed',
579
+ before: b.hash,
580
+ after: a.hash,
581
+ })
582
+ } else if (a) {
583
+ entries.push({ kind: a.kind, name: a.name, change: 'added', before: null, after: a.hash })
584
+ } else if (b) {
585
+ entries.push({ kind: b.kind, name: b.name, change: 'removed', before: b.hash, after: null })
586
+ }
587
+ }
588
+ // Code-unit order, not `localeCompare`: collation follows the machine's
589
+ // locale, and the playground runs this same join in the reader's browser,
590
+ // so the CLI and the page could list one diff in two orders. Code units
591
+ // are also SQLite's BINARY order, the `entry_inputs` scan's own.
592
+ const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
593
+ entries.sort((x, y) => byCodeUnit(x.kind, y.kind) || byCodeUnit(x.name, y.name))
594
+ return { entries, unchangedCount }
492
595
  }
493
596
 
494
597
  /**
@@ -503,7 +606,8 @@ function loadEntryInputs(db: Database, entryHash: string): Map<string, EntryInpu
503
606
  * - only in the previous run → `removed`
504
607
  * - equal → counted as unchanged
505
608
  *
506
- * Pure SQL + an app-side set join — no config re-evaluation, no re-hash.
609
+ * Pure SQL + an app-side set join (`diffKeyComponents`) — no config
610
+ * re-evaluation, no re-hash.
507
611
  * Always returns a value (never throws); `found:false` when the run/task pair
508
612
  * has no row, `entries:[]` for the first run of a task.
509
613
  */
@@ -511,10 +615,10 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
511
615
  const [project, task] = splitTaskId(taskId)
512
616
  const this_ = db
513
617
  .query(
514
- 'SELECT hash, cached, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
618
+ 'SELECT id, hash, cached, status, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
515
619
  )
516
620
  .get(runId, project, task) as
517
- | { hash: string; cached: number | null; startedAt: number }
621
+ | { id: number; hash: string; cached: number | null; status: string; startedAt: number }
518
622
  | undefined
519
623
  if (!this_) {
520
624
  return {
@@ -545,11 +649,13 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
545
649
 
546
650
  const prev = db
547
651
  .query(
548
- `SELECT run_id AS runId, hash FROM runs
549
- WHERE project = ? AND task = ? AND started_at < ? AND ${KEYED_RUNS_SQL}
550
- ORDER BY started_at DESC LIMIT 1`,
652
+ `SELECT run_id AS runId, hash, status FROM runs
653
+ WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
654
+ ORDER BY id DESC LIMIT 1`,
551
655
  )
552
- .get(project, task, this_.startedAt) as { runId: string | null; hash: string } | undefined
656
+ .get(project, task, this_.id) as
657
+ | { runId: string | null; hash: string; status: string }
658
+ | undefined
553
659
 
554
660
  if (!prev) {
555
661
  return {
@@ -581,7 +687,15 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
581
687
  // Input fingerprints are pruned with their entry (ON DELETE CASCADE); if
582
688
  // either side's rows are gone, we can name the hash change but not the
583
689
  // component-level diff.
584
- if (cur.size === 0 || old.size === 0) {
690
+ if (cur.length === 0 || old.length === 0) {
691
+ // A run that did not pass saved no entry, so it kept no fingerprints:
692
+ // that is the cause to name, not a prune (item 898).
693
+ const unsaved =
694
+ cur.length === 0 && !isPassStatus(this_.status)
695
+ ? `this run ended ${this_.status}`
696
+ : old.length === 0 && !isPassStatus(prev.status)
697
+ ? `the previous run ended ${prev.status}`
698
+ : null
585
699
  return {
586
700
  runId,
587
701
  taskId,
@@ -591,34 +705,16 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
591
705
  unchangedCount: 0,
592
706
  note:
593
707
  this_.cached === 0
594
- ? 'this task declares no `cache` block — it runs every time; its key moved, but no input fingerprints exist for an uncached task'
595
- : 'cache key changed but input fingerprints are unavailable — the entry was pruned (or the row predates the `cached` column); only the key change is known',
708
+ ? // The verdict already says the task is uncached; this line says
709
+ // what moved a key that declares nothing.
710
+ 'no `cache` block declares its inputs, so its key folds every file in its project — a file it wrote, or any untracked file, moves it — and no fingerprints are kept to say which'
711
+ : unsaved !== null
712
+ ? `cache key changed but input fingerprints are unavailable — ${unsaved} and saved no entry; only the key change is known`
713
+ : 'cache key changed but input fingerprints are unavailable — the entry was pruned (or the row predates the `cached` column); only the key change is known',
596
714
  }
597
715
  }
598
716
 
599
- const entries: InputDiffEntry[] = []
600
- let unchangedCount = 0
601
- const keys = new Set<string>([...cur.keys(), ...old.keys()])
602
- for (const key of keys) {
603
- const a = cur.get(key)
604
- const b = old.get(key)
605
- if (a && b) {
606
- if (a.hash === b.hash) unchangedCount++
607
- else
608
- entries.push({
609
- kind: a.kind,
610
- name: a.name,
611
- change: 'changed',
612
- before: b.hash,
613
- after: a.hash,
614
- })
615
- } else if (a) {
616
- entries.push({ kind: a.kind, name: a.name, change: 'added', before: null, after: a.hash })
617
- } else if (b) {
618
- entries.push({ kind: b.kind, name: b.name, change: 'removed', before: b.hash, after: null })
619
- }
620
- }
621
- entries.sort((x, y) => x.kind.localeCompare(y.kind) || x.name.localeCompare(y.name))
717
+ const { entries, unchangedCount } = diffKeyComponents(old, cur)
622
718
 
623
719
  return {
624
720
  runId,