@vzn/vx 0.0.638 → 0.0.639

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 (103) hide show
  1. package/docs/caching.md +44 -15
  2. package/docs/cli.md +168 -345
  3. package/docs/execution.md +18 -43
  4. package/docs/features.md +7 -10
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +47 -49
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/skills/vx/SKILL.md +6 -4
  10. package/src/cache/cache.ts +40 -10
  11. package/src/cache/git-inputs.ts +6 -0
  12. package/src/cache/index.ts +2 -2
  13. package/src/cache/layer.ts +25 -11
  14. package/src/cache/layered-cache.ts +108 -315
  15. package/src/cache/plugin-cache.ts +2 -10
  16. package/src/cache/remote-lane.ts +383 -0
  17. package/src/cache/run-history.ts +24 -8
  18. package/src/cache/run-store.ts +4 -4
  19. package/src/cache/schema.ts +6 -4
  20. package/src/cache/scoped-cache.ts +7 -0
  21. package/src/cli/completions.ts +2 -2
  22. package/src/cli/foreign-flags.ts +10 -22
  23. package/src/cli/help.ts +6 -31
  24. package/src/cli/info.ts +2 -1
  25. package/src/cli/last.ts +6 -3
  26. package/src/cli/plan-format.ts +5 -58
  27. package/src/cli/plugin-commands.ts +2 -2
  28. package/src/cli/run.ts +23 -185
  29. package/src/cli/show.ts +6 -0
  30. package/src/cli/upgrade.ts +123 -39
  31. package/src/cli/watch-cycle.ts +2 -1
  32. package/src/cli/watch.ts +29 -14
  33. package/src/cli/why.ts +4 -4
  34. package/src/config.ts +0 -6
  35. package/src/exec/executor.ts +1 -1
  36. package/src/exec/sandbox-runtime.ts +1 -1
  37. package/src/exec/sandbox-violations.ts +3 -1
  38. package/src/graph/index.ts +9 -1
  39. package/src/graph/priorities.ts +1 -1
  40. package/src/graph/scheduler.ts +4 -16
  41. package/src/graph/task-graph.ts +87 -17
  42. package/src/graph/task-status.ts +95 -0
  43. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  44. package/src/{orchestrator → history}/history.ts +1 -1
  45. package/src/history/index.ts +48 -0
  46. package/src/{orchestrator → history}/metrics.ts +7 -20
  47. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  48. package/src/index.ts +10 -28
  49. package/src/orchestrator/doctor.ts +1 -1
  50. package/src/orchestrator/execute-args.ts +80 -0
  51. package/src/orchestrator/execute-task.ts +21 -386
  52. package/src/orchestrator/fingerprint-watch.ts +2 -2
  53. package/src/orchestrator/index.ts +1 -73
  54. package/src/orchestrator/miss-save.ts +33 -3
  55. package/src/orchestrator/options.ts +1 -36
  56. package/src/orchestrator/output-host.ts +4 -3
  57. package/src/orchestrator/persistent-task.ts +321 -0
  58. package/src/orchestrator/persistent.ts +130 -3
  59. package/src/orchestrator/placement.ts +3 -3
  60. package/src/orchestrator/plan.ts +18 -5
  61. package/src/orchestrator/plugin-host.ts +67 -47
  62. package/src/orchestrator/plugin.ts +6 -6
  63. package/src/orchestrator/prepare.ts +28 -29
  64. package/src/orchestrator/remote-prefetch.ts +14 -1
  65. package/src/orchestrator/run-artifacts.ts +5 -177
  66. package/src/orchestrator/run-context.ts +55 -0
  67. package/src/orchestrator/run-failures.ts +15 -3
  68. package/src/orchestrator/run-records.ts +35 -3
  69. package/src/orchestrator/run-report.ts +81 -179
  70. package/src/orchestrator/run-settings.ts +1 -16
  71. package/src/orchestrator/run-stop.ts +153 -0
  72. package/src/orchestrator/run.ts +260 -601
  73. package/src/orchestrator/sandbox-fix.ts +0 -16
  74. package/src/orchestrator/signals.ts +14 -6
  75. package/src/orchestrator/stable-keys.ts +107 -3
  76. package/src/orchestrator/steps.ts +50 -0
  77. package/src/orchestrator/task-hash.ts +3 -16
  78. package/src/orchestrator/task-log-buffer.ts +1 -1
  79. package/src/orchestrator/task-process.ts +94 -0
  80. package/src/orchestrator/telemetry-host.ts +1 -1
  81. package/src/orchestrator/telemetry.ts +5 -82
  82. package/src/{orchestrator → report}/events.ts +41 -8
  83. package/src/{orchestrator → report}/forecast.ts +35 -2
  84. package/src/{orchestrator → report}/framed-output.ts +39 -33
  85. package/src/report/index.ts +36 -0
  86. package/src/{orchestrator → report}/logger.ts +158 -20
  87. package/src/{orchestrator → report}/status-line.ts +84 -7
  88. package/src/{orchestrator → report}/summary.ts +126 -35
  89. package/src/report/tally.ts +83 -0
  90. package/src/settings.ts +1 -22
  91. package/src/util/colors.ts +44 -4
  92. package/src/util/git-ignored.ts +143 -0
  93. package/src/util/index.ts +19 -4
  94. package/src/util/report.ts +13 -5
  95. package/src/util/settle.ts +14 -39
  96. package/src/util/tail.ts +1 -1
  97. package/src/util/ui.ts +17 -6
  98. package/schemas/summary.json +0 -133
  99. package/src/cache/chained-cache.ts +0 -280
  100. package/src/orchestrator/tally.ts +0 -112
  101. /package/src/{orchestrator → report}/path-links.ts +0 -0
  102. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  103. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -2,7 +2,7 @@
2
2
  // p50 in local history. A display aid for the live region only: it never
3
3
  // steers the scheduler and never reaches the final summary.
4
4
 
5
- import { isGroupTask, type TaskNode } from '../graph/index.js'
5
+ import { computeReverseDepCount, isGroupTask, type TaskNode } from '../graph/index.js'
6
6
 
7
7
  /** What the live region says about the rest of a run. */
8
8
  export interface LiveForecast {
@@ -14,6 +14,18 @@ export interface LiveForecast {
14
14
  blocks: ReadonlyMap<string, number>
15
15
  /** Each running task's p50, where history knows it: the row's expected duration. */
16
16
  expected?: ReadonlyMap<string, number>
17
+ /**
18
+ * Tasks not started because a dependency has not finished, in the order
19
+ * the scheduler would start them, at most one per worker: what an idle
20
+ * worker row holds (M, 2026-10-15).
21
+ */
22
+ blocked?: readonly BlockedTask[]
23
+ }
24
+
25
+ /** A task an unfinished dependency holds back, and what holds it: the running task its longest chain waits on, else that dependency. */
26
+ export interface BlockedTask {
27
+ id: string
28
+ by: string
17
29
  }
18
30
 
19
31
  export type Forecast = (
@@ -68,6 +80,14 @@ export function createForecast(
68
80
  if (p !== undefined) cost.set(n.id, p)
69
81
  }
70
82
  const workers = Math.max(1, concurrency)
83
+ // The scheduler's own baseline ranking, most dependents first, ties in
84
+ // graph order: once per run, as the scheduler computes it.
85
+ const rank = computeReverseDepCount(nodes)
86
+ const position = new Map(order.map((n, i) => [n.id, i]))
87
+ const before = (a: string, b: string): boolean => {
88
+ const d = (rank.get(a) ?? 0) - (rank.get(b) ?? 0)
89
+ return d !== 0 ? d > 0 : position.get(a)! < position.get(b)!
90
+ }
71
91
 
72
92
  const dependents = new Map<string, string[]>()
73
93
  for (const n of order)
@@ -106,6 +126,8 @@ export function createForecast(
106
126
  let work = 0
107
127
  let known = false
108
128
  let overrun = false
129
+ // The best `workers` blocked tasks, kept sorted as the pass meets them.
130
+ const held: BlockedTask[] = []
109
131
  for (const n of order) {
110
132
  if (finished.has(n.id)) continue
111
133
  let start = 0
@@ -120,6 +142,16 @@ export function createForecast(
120
142
  const since = running.get(n.id)
121
143
  if (since !== undefined) head.set(n.id, n.id)
122
144
  else if (via !== undefined && head.has(via)) head.set(n.id, head.get(via)!)
145
+ const by =
146
+ since === undefined ? (head.get(n.id) ?? n.deps.find((d) => end.has(d))) : undefined
147
+ if (by !== undefined && !isGroupTask(n)) {
148
+ let at = held.length
149
+ while (at > 0 && before(n.id, held[at - 1]!.id)) at--
150
+ if (at < workers) {
151
+ held.splice(at, 0, { id: n.id, by })
152
+ if (held.length > workers) held.pop()
153
+ }
154
+ }
123
155
  const p = cost.get(n.id)
124
156
  let own = 0
125
157
  if (p !== undefined) {
@@ -135,13 +167,14 @@ export function createForecast(
135
167
  }
136
168
  work += own
137
169
  }
138
- if (!known || overrun) return { blocks, expected }
170
+ if (!known || overrun) return { blocks, expected, blocked: held }
139
171
  const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
140
172
  return {
141
173
  etaMs: Math.max(chain, work / workers),
142
174
  ...(critical !== undefined ? { critical } : {}),
143
175
  blocks,
144
176
  expected,
177
+ blocked: held,
145
178
  }
146
179
  }
147
180
  }
@@ -27,32 +27,27 @@
27
27
 
28
28
  import { withForwardArgs } from '../exec/index.js'
29
29
  import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
30
- import { formatElapsed, maskedCommand, paint, type ColorSupport } from '../util/index.js'
30
+ import {
31
+ formatElapsed,
32
+ identityColor,
33
+ maskedCommand,
34
+ paint,
35
+ type ColorSupport,
36
+ } from '../util/index.js'
31
37
  import { neverStarted } from './summary.js'
32
38
  import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
33
39
  import { fileMemo, linkPaths } from './path-links.js'
34
- import { sandboxFixLine } from './sandbox-fix.js'
35
40
 
36
41
  const NO_COLOR: ColorSupport = { enabled: false }
37
42
 
38
43
  const ACCENT = '#06b6d4' // cyan-500 — bullets, remote-hit hint
39
- // Identity hues — deliberately outside the status palette (green /
40
- // red / yellow / cyan) so a task id can never read as an outcome.
41
- // Projects hash to a stable hue (same project = same color in every
42
- // run, list, and region row); tasks keep one fixed hue excluded from
43
- // the project palette so the two halves always read apart.
44
+ // Identity hues (`identityColor`) are outside the status palette (green /
45
+ // red / yellow / cyan) so a task id can never read as an outcome. Each
46
+ // half of an id hashes its own name: same project, same hue; same target,
47
+ // same hue, in every run, list and region row.
44
48
  // Section rules + the summary rule share one frame width.
45
49
  const FRAME_WIDTH = 60
46
50
 
47
- const PROJECT_PALETTE = [
48
- '#a78bfa', // violet-400
49
- '#60a5fa', // blue-400
50
- '#e879f9', // fuchsia-400
51
- '#818cf8', // indigo-400
52
- '#c084fc', // purple-400
53
- '#93c5fd', // blue-300
54
- ] as const
55
- const TASK = '#f472b6' // pink-400 — task part of an id
56
51
  const SUCCESS = '#22c55e' // green-500 — success / fresh
57
52
  const WARN = '#eab308' // yellow-500 — skipped
58
53
  const ERROR = '#ef4444' // red-500 — failed
@@ -209,7 +204,8 @@ function pushOutputSection(
209
204
  function sectionLine(title: string, color: string, colors: ColorSupport): string {
210
205
  const corner = (s: string) => paint('', s, colors, { dim: true })
211
206
  const label = paint(color, title, colors, { bold: true })
212
- const rule = corner('─'.repeat(Math.max(1, FRAME_WIDTH - 4 - title.length)))
207
+ const width = Math.min(FRAME_WIDTH, colors.columns ?? FRAME_WIDTH)
208
+ const rule = corner('─'.repeat(Math.max(1, width - 4 - title.length)))
213
209
  return `${corner('├─')} ${label} ${rule}`
214
210
  }
215
211
 
@@ -222,18 +218,9 @@ function pushBodyLines(lines: string[], text: string): void {
222
218
  for (const line of text.replace(/\n$/, '').split('\n')) lines.push(line)
223
219
  }
224
220
 
225
- /** Stable per-project hue: FNV-style fold of the name into the palette. */
226
- function projectColor(projectName: string): string {
227
- let h = 0
228
- for (let i = 0; i < projectName.length; i++) {
229
- h = (h * 31 + projectName.charCodeAt(i)) >>> 0
230
- }
231
- return PROJECT_PALETTE[h % PROJECT_PALETTE.length]!
232
- }
233
-
234
221
  /**
235
- * `project#task` halves in distinct identity hues (never status
236
- * colors); the dim separator keeps them reading apart. `hueSource`
222
+ * `project#task` halves, each in its name's identity hue (never a status
223
+ * color); the dim separator keeps them reading apart. `hueSource`
237
224
  * exists for truncated display strings: the hue must hash from the
238
225
  * FULL project name so it survives truncation.
239
226
  */
@@ -242,13 +229,14 @@ export function paintIdParts(
242
229
  projectText: string,
243
230
  taskText: string,
244
231
  colors: ColorSupport,
245
- opts: { bold?: boolean } = {},
232
+ opts: { bold?: boolean; sep?: '#' | ':' } = {},
246
233
  ): string {
247
234
  const dim = (t: string) => paint('', t, colors, { dim: true })
235
+ const { sep = '#', ...style } = opts
248
236
  return (
249
- paint(projectColor(hueSource), projectText, colors, opts) +
250
- dim('#') +
251
- paint(TASK, taskText, colors, opts)
237
+ paint(identityColor(hueSource), projectText, colors, style) +
238
+ dim(sep) +
239
+ paint(identityColor(taskText), taskText, colors, style)
252
240
  )
253
241
  }
254
242
 
@@ -553,8 +541,10 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
553
541
  : []),
554
542
  '',
555
543
  ]
544
+ // vx's own notes (`vx: …`) explain a failure; they are not denials.
545
+ const denials = body.filter((l) => !l.startsWith('vx: ')).length
556
546
  return [
557
- sectionLine(`SANDBOX VIOLATIONS (${body.length})`, ERROR, colors),
547
+ sectionLine(denials > 0 ? `SANDBOX VIOLATIONS (${denials})` : 'SANDBOX', ERROR, colors),
558
548
  '',
559
549
  ...body,
560
550
  '',
@@ -704,3 +694,19 @@ function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
704
694
  return o.status
705
695
  }
706
696
  }
697
+
698
+ /**
699
+ * The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
700
+ * or undefined when every denial was a withheld credential path.
701
+ */
702
+ function sandboxFixLine({
703
+ withheld: _,
704
+ inputs: __,
705
+ ...fix
706
+ }: NonNullable<TaskOutcome['sandboxFix']>): string | undefined {
707
+ if (Object.keys(fix).length === 0) return undefined
708
+ const parts = Object.entries(fix).map(
709
+ ([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,
710
+ )
711
+ return `exec.sandbox.allow: { ${parts.join(', ')} }`
712
+ }
@@ -0,0 +1,36 @@
1
+ // What a run tells the people and programs watching it: the event stream
2
+ // every output surface subscribes to, the terminal renderer (framed task
3
+ // output, the status line, the summary, the forecast) and the plain
4
+ // output for non-TTY runs. It renders outcomes it is handed; it never
5
+ // runs a task or reads the cache.
6
+ export {
7
+ busLogger,
8
+ createEventBus,
9
+ type EventBus,
10
+ type OutcomeView,
11
+ outcomeWord,
12
+ projectNode,
13
+ projectOutcome,
14
+ ranNoCache,
15
+ type RunEvent,
16
+ type RunEventSubscriber,
17
+ type RunStartInfo,
18
+ type TaskView,
19
+ terminalSubscriber,
20
+ toWireEvent,
21
+ type WireEvent,
22
+ wireForwarder,
23
+ } from './events.js'
24
+ export {
25
+ type DefaultLogger,
26
+ defaultLogger,
27
+ type Logger,
28
+ type OutputView,
29
+ resolveOutputView,
30
+ warnOf,
31
+ } from './logger.js'
32
+ export { formatPersistentList } from './framed-output.js'
33
+ export { createForecast } from './forecast.js'
34
+ export { fileLocations, fileMemo, type OutputLocation } from './path-links.js'
35
+ export { plainOutput } from './plain-output.js'
36
+ export { formatOutcomeTable, formatRunSummary } from './summary.js'
@@ -17,10 +17,18 @@ import {
17
17
  createOutputWriter,
18
18
  formatFailureLine,
19
19
  formatStatusRegion,
20
+ formatStepLine,
20
21
  type StatusStream,
21
22
  type WorkerSlot,
22
23
  } from './status-line.js'
23
- import { formatSummarySection, neverStarted, ranUnsaved, type RunContext } from './summary.js'
24
+ import {
25
+ cacheBucketOf,
26
+ formatSummarySection,
27
+ neverStarted,
28
+ ranUnsaved,
29
+ type CacheBucket,
30
+ type RunContext,
31
+ } from './summary.js'
24
32
  import { isGroupTask } from '../graph/index.js'
25
33
  import {
26
34
  appendTail,
@@ -32,9 +40,9 @@ import {
32
40
  detectColors,
33
41
  type ColorSupport,
34
42
  } from '../util/index.js'
35
- import { isCacheHit } from './telemetry.js'
43
+ import { isCacheHit } from '../graph/index.js'
36
44
  import { failedLabel, outcomeWord, ranNoCache } from './events.js'
37
- import type { Forecast } from './forecast.js'
45
+ import type { BlockedTask, Forecast } from './forecast.js'
38
46
  import {
39
47
  notifyEscape,
40
48
  PROGRESS_CLEAR,
@@ -46,6 +54,12 @@ import {
46
54
  export interface Logger {
47
55
  /** Header / footer / status text. Written verbatim, one trailing \n added. */
48
56
  status(line: string): void
57
+ /**
58
+ * A warning: something worked, but look at it. The default logger writes
59
+ * it to stderr, so stdout carries only the rows and the footer. A logger
60
+ * without it gets warnings through `status`.
61
+ */
62
+ warn?(line: string): void
49
63
  /** Streamed stdout chunk for a task. Buffered until `taskComplete`. */
50
64
  taskStdout(node: TaskNode, chunk: string): void
51
65
  /** Streamed stderr chunk for a task. Buffered until `taskComplete`. */
@@ -70,8 +84,21 @@ export interface Logger {
70
84
  startedAtMs?: number
71
85
  }): void
72
86
  taskStart?(node: TaskNode): void
73
- /** Run finished (any outcome). Idempotent. */
87
+ /** The task graph finished (any outcome); vx's own closing steps follow. Idempotent. */
74
88
  runEnd?(): void
89
+ /**
90
+ * One of vx's own steps (`vx:keys`, `@vzn/vx-otel:setup`) began or ended.
91
+ * Not a task: no stat, history row or telemetry record counts it.
92
+ */
93
+ stepStart?(id: string): void
94
+ stepEnd?(id: string, info: { durationMs: number; ok: boolean }): void
95
+ /** The run's work is done; the footer follows. Idempotent. */
96
+ runDone?(): void
97
+ }
98
+
99
+ /** `log.warn`, or `log.status` for a logger without it. */
100
+ export function warnOf(log: Logger): (line: string) => void {
101
+ return log.warn !== undefined ? (line) => log.warn!(line) : (line) => log.status(line)
75
102
  }
76
103
 
77
104
  /** The logger `run()` builds when none is passed. */
@@ -82,6 +109,12 @@ export interface DefaultLogger extends Logger {
82
109
  * throw that never reached `runEnd`.
83
110
  */
84
111
  settle(): void
112
+ /**
113
+ * Whether vx's own steps show: only beside a live region, on a terminal.
114
+ * A log (CI, a pipe) stays free of rows whose presence is a timing.
115
+ * When false, run() never times a step (zero cost).
116
+ */
117
+ readonly showsSteps: boolean
85
118
  /**
86
119
  * Hand the live region a way to predict the run's end. Called once at
87
120
  * run start; `load` runs only if the region is still live a second in,
@@ -192,6 +225,8 @@ export function resolveOutputView(
192
225
  const FORECAST_AFTER_MS = 1000
193
226
  /** A run shorter than this ends without a desktop notification. */
194
227
  const NOTIFY_AFTER_MS = 10_000
228
+ // A step of vx's own quicker than this shows nowhere (M, 2026-10-15).
229
+ const STEP_SHOWN_MS = 150
195
230
 
196
231
  export function defaultLogger(
197
232
  colors: ColorSupport = detectColors(),
@@ -207,9 +242,12 @@ export function defaultLogger(
207
242
  footer?: boolean
208
243
  /** The environment that names the terminal (`TERM_PROGRAM`, …); `process.env` by default. */
209
244
  env?: Readonly<Record<string, string | undefined>>
245
+ /** Where warnings go; `process.stderr` by default. */
246
+ err?: StatusStream
210
247
  } = {},
211
248
  ): DefaultLogger {
212
249
  const forwardArgs = opts.forwardArgs ?? []
250
+ const errOut = opts.err ?? process.stderr
213
251
  // Per-task buffers, split by stream. Splitting lets the framed-output
214
252
  // renderer put stdout under `├─ stdout` and stderr under `├─ stderr`.
215
253
  // The price: chunks that interleaved at runtime get re-ordered (all
@@ -303,6 +341,10 @@ export function defaultLogger(
303
341
  let critical: string | undefined
304
342
  let blocks: ReadonlyMap<string, number> | undefined
305
343
  let expected: ReadonlyMap<string, number> | undefined
344
+ let blocked: readonly BlockedTask[] | undefined
345
+ // The blocked task each idle worker row holds, by slot: a row keeps its
346
+ // task until it starts, in that row, or leaves the list, so nothing hops.
347
+ const heldRows: (string | null)[] = []
306
348
  let etaAtMs = 0
307
349
  const signals = writer.enabled
308
350
  ? terminalSignals(opts.env ?? process.env)
@@ -334,6 +376,15 @@ export function defaultLogger(
334
376
  let notSaved = 0
335
377
  let skippedCount = 0
336
378
  let savedMs = 0
379
+ // Each finished task's cache bucket by its key: its remote traffic moves it.
380
+ const buckets = new Map<string, CacheBucket>()
381
+ // The graph is done and vx's own closing steps run: the region stays,
382
+ // footer and step rows, until the footer replaces it (M, 2026-10-15).
383
+ let tail = false
384
+ // No footer before the run starts: vx's opening steps show alone.
385
+ let started = false
386
+ // vx's own steps running now, by id: a row once one passes STEP_SHOWN_MS.
387
+ const steps = new Map<string, number>()
337
388
  // Cache-miss duration spread, accumulated incrementally (the same
338
389
  // numbers the final summary computes from the outcome list).
339
390
  let spreadMax = 0
@@ -409,7 +460,7 @@ export function defaultLogger(
409
460
  if (statusDead) return
410
461
  const now = Date.now()
411
462
  // Twice a second at most: one pass over the unfinished graph.
412
- if (forecastFn !== undefined && (force || now - etaAtMs >= 500)) {
463
+ if (forecastFn !== undefined && !tail && (force || now - etaAtMs >= 500)) {
413
464
  etaAtMs = now
414
465
  const running = new Map<string, number>()
415
466
  for (const s of slots) if (s !== null) running.set(s.id, s.startedMs)
@@ -419,6 +470,7 @@ export function defaultLogger(
419
470
  critical = f.critical
420
471
  blocks = f.blocks
421
472
  expected = f.expected
473
+ blocked = f.blocked
422
474
  }
423
475
  if (signals.progress && total > 0) {
424
476
  const elapsed = now - startedAtMs
@@ -437,7 +489,7 @@ export function defaultLogger(
437
489
  // The live summary IS the final summary's section, built from the
438
490
  // same formatter so the region visually becomes the printout.
439
491
  const summaryLines =
440
- opts.footer === false
492
+ opts.footer === false || !started
441
493
  ? []
442
494
  : formatSummarySection(
443
495
  {
@@ -454,7 +506,8 @@ export function defaultLogger(
454
506
  noCache,
455
507
  savedMs,
456
508
  left: total - done,
457
- ...(etaMs !== undefined ? { etaMs } : {}),
509
+ ...(etaMs !== undefined && !tail ? { etaMs } : {}),
510
+ buckets,
458
511
  spread:
459
512
  spreadCount > 0
460
513
  ? { maxMs: spreadMax, minMs: spreadMin, sumMs: spreadSum, count: spreadCount }
@@ -464,12 +517,18 @@ export function defaultLogger(
464
517
  colors,
465
518
  runContext,
466
519
  )
520
+ const held = blocked === undefined ? undefined : holdRows(blocked)
521
+ const shownSteps: WorkerSlot[] = []
522
+ for (const [id, startedMs] of steps)
523
+ if (now - startedMs >= STEP_SHOWN_MS) shownSteps.push({ id, startedMs })
467
524
  writer.setRegion(
468
525
  formatStatusRegion(
469
526
  {
470
527
  pinnedPersistent,
471
528
  slots: slots.slice(0, shownSlots),
529
+ ...(held !== undefined ? { blocked: held } : {}),
472
530
  overflow: slotQueue.length,
531
+ ...(shownSteps.length > 0 ? { steps: shownSteps } : {}),
473
532
  nowMs: Date.now(),
474
533
  summaryLines,
475
534
  ...(blocks !== undefined ? { blocks } : {}),
@@ -481,6 +540,46 @@ export function defaultLogger(
481
540
  { force },
482
541
  )
483
542
  }
543
+ // Each idle worker row holds a blocked task, in scheduler order. A row
544
+ // with none left to hold leaves the region (M, 2026-10-15): blocked tasks
545
+ // only dwindle, so it is never needed again, and the rows below close up.
546
+ const holdRows = (list: readonly BlockedTask[]): (BlockedTask | null)[] => {
547
+ const started = new Set<string>()
548
+ for (const s of slots) if (s !== null) started.add(s.id)
549
+ for (const s of slotQueue) started.add(s.id)
550
+ const open = new Map<string, BlockedTask>()
551
+ for (const b of list) if (!started.has(b.id)) open.set(b.id, b)
552
+ const rows: (BlockedTask | null)[] = []
553
+ for (let i = 0; i < shownSlots; i++) {
554
+ const kept = slots[i] === null ? open.get(heldRows[i] ?? '') : undefined
555
+ rows.push(kept ?? null)
556
+ if (kept !== undefined) open.delete(kept.id)
557
+ }
558
+ const rest = open.values()
559
+ for (let i = 0; i < shownSlots; i++)
560
+ if (slots[i] === null && rows[i] === null) rows[i] = rest.next().value ?? null
561
+ for (let i = shownSlots - 1; i >= 0; i--) {
562
+ if (slots[i] !== null || rows[i] !== null) continue
563
+ rows.splice(i, 1)
564
+ slots.splice(i, 1)
565
+ slots.push(null)
566
+ shownSlots--
567
+ }
568
+ heldRows.length = 0
569
+ for (const r of rows) heldRows.push(r?.id ?? null)
570
+ return rows
571
+ }
572
+ const tick = (): void => {
573
+ if (!writer.enabled || statusDead || ticker !== null) return
574
+ // Keeps the per-worker elapsed time ticking between task events
575
+ // (the time IS the motion — there's no spinner). The writer
576
+ // throttles unforced redraws, and unref means a stray ticker
577
+ // can never hold the process open.
578
+ ticker = setInterval(() => {
579
+ refresh(false)
580
+ }, 100)
581
+ ticker.unref?.()
582
+ }
484
583
  const killStatus = (): void => {
485
584
  if (ticker !== null) {
486
585
  clearInterval(ticker)
@@ -504,7 +603,11 @@ export function defaultLogger(
504
603
  // no task row (`status`). Without a footer, the caller frames the log
505
604
  // (`vx watch` opens each cycle with its own line).
506
605
  let logOpened = opts.footer === false
606
+ // The log ends on a blank line (a block's own): a blank status line
607
+ // after it (the footer's) would make two; one blank line apart is the rule.
608
+ let endsBlank = false
507
609
  const taskWrite = (chunk: string): void => {
610
+ if (chunk.length > 0) endsBlank = chunk.endsWith('\n\n')
508
611
  if (!logOpened && chunk.length > 0) {
509
612
  logOpened = true
510
613
  writer.write(`\n${chunk}`)
@@ -559,6 +662,7 @@ export function defaultLogger(
559
662
  flakyNote(outcome, colors)
560
663
 
561
664
  return {
665
+ showsSteps: writer.enabled,
562
666
  settle() {
563
667
  // The bus delivers one `run:end`, so the CLI's last word reaches here.
564
668
  flushKept()
@@ -574,28 +678,46 @@ export function defaultLogger(
574
678
  writer.write('\n')
575
679
  streamMidLine = false
576
680
  }
681
+ if (line === '' && endsBlank) return
682
+ endsBlank = line === ''
577
683
  writer.write(`${line}\n`)
578
684
  },
685
+ warn(line) {
686
+ flushKept()
687
+ if (streamMidLine) {
688
+ writer.write('\n')
689
+ streamMidLine = false
690
+ }
691
+ writer.writeTo(errOut, `${line}\n`)
692
+ },
579
693
  runStart(info) {
580
694
  total = info.total
581
695
  requestedCount = info.requestedCount ?? requestedCount
582
696
  runContext = info.context
583
697
  startedAtMs = Date.now()
698
+ started = true
584
699
  const cap = Math.max(1, Math.min(info.concurrency ?? 10, 10))
585
700
  slots = Array.from({ length: cap }, () => null)
586
701
  shownSlots = 0
587
- if (writer.enabled && !statusDead && ticker === null) {
588
- // Keeps the per-worker elapsed time ticking between task events
589
- // (the time IS the motion — there's no spinner). The writer
590
- // throttles unforced redraws, and unref means a stray ticker
591
- // can never hold the process open.
592
- ticker = setInterval(() => {
593
- refresh(false)
594
- }, 100)
595
- ticker.unref?.()
596
- }
702
+ tick()
597
703
  refresh(true)
598
704
  },
705
+ stepStart(id) {
706
+ steps.set(id, Date.now())
707
+ // Drawn by the ticker once it passes STEP_SHOWN_MS; a quicker one never is.
708
+ tick()
709
+ },
710
+ stepEnd(id, { durationMs, ok }) {
711
+ steps.delete(id)
712
+ if (durationMs < STEP_SHOWN_MS) return
713
+ if (ok ? view.mode !== 'none' && view.mode !== 'errors-only' : view.mode !== 'none')
714
+ emitLine(formatStepLine(id, durationMs, ok, colors))
715
+ refresh(true)
716
+ },
717
+ runDone() {
718
+ tail = false
719
+ killStatus()
720
+ },
599
721
  taskStart(node) {
600
722
  if (isGroupTask(node)) return
601
723
  if (holdsTerminal(node)) {
@@ -619,7 +741,14 @@ export function defaultLogger(
619
741
  persistentTails.set(node.id, { node, out: createTail(), err: createTail() })
620
742
  }
621
743
  const slot: WorkerSlot = { id: node.id, startedMs: Date.now() }
622
- const free = slots.indexOf(null)
744
+ // A blocked task starts in the row that held it; another takes a free
745
+ // row, else the last held one, whose task moves to the next free row.
746
+ const own = heldRows.indexOf(node.id)
747
+ let free = own >= 0 && slots[own] === null ? own : -1
748
+ if (free < 0)
749
+ free = slots.findIndex((s, i) => s === null && i < shownSlots && heldRows[i] == null)
750
+ if (free < 0) free = slots.findLastIndex((s, i) => s === null && i < shownSlots)
751
+ if (free < 0) free = slots.indexOf(null)
623
752
  if (free >= 0) {
624
753
  slots[free] = slot
625
754
  shownSlots = Math.max(shownSlots, free + 1)
@@ -645,7 +774,7 @@ export function defaultLogger(
645
774
  runEnd() {
646
775
  // A long run says it ended where the user is not looking. Once:
647
776
  // the status is dead after the first runEnd.
648
- if (signals.notify && !statusDead && Date.now() - startedAtMs >= NOTIFY_AFTER_MS) {
777
+ if (signals.notify && !statusDead && !tail && Date.now() - startedAtMs >= NOTIFY_AFTER_MS) {
649
778
  out.write(
650
779
  notifyEscape(
651
780
  failed > 0
@@ -654,7 +783,14 @@ export function defaultLogger(
654
783
  ),
655
784
  )
656
785
  }
657
- killStatus()
786
+ if (!tail && !statusDead) {
787
+ tail = true
788
+ slots = []
789
+ blocked = undefined
790
+ shownSlots = 0
791
+ slotQueue.length = 0
792
+ refresh(true)
793
+ } else if (!tail) killStatus()
658
794
  // Persistent tails first: they are context for whatever ran against
659
795
  // the server, so they read above the failures they explain. `none`
660
796
  // prints no per-task output and `errors-only` only failed tasks: a
@@ -837,6 +973,8 @@ export function defaultLogger(
837
973
  const qi = slotQueue.findIndex((s) => s.id === node.id)
838
974
  if (qi >= 0) slotQueue.splice(qi, 1)
839
975
  }
976
+ const bucket = outcome.hash === undefined ? undefined : cacheBucketOf(outcome)
977
+ if (bucket !== undefined) buckets.set(outcome.hash!, bucket)
840
978
  switch (outcome.status) {
841
979
  case 'success':
842
980
  succeeded++