@vzn/vx 0.0.637 → 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 (126) hide show
  1. package/docs/caching.md +84 -28
  2. package/docs/cli.md +273 -408
  3. package/docs/execution.md +19 -44
  4. package/docs/features.md +13 -15
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +68 -61
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/schemas/plan.json +2 -2
  10. package/skills/vx/SKILL.md +19 -6
  11. package/src/bin.ts +13 -7
  12. package/src/cache/cache.ts +60 -11
  13. package/src/cache/git-inputs.ts +6 -0
  14. package/src/cache/index.ts +10 -2
  15. package/src/cache/inputs.ts +0 -24
  16. package/src/cache/layer.ts +32 -12
  17. package/src/cache/layered-cache.ts +112 -315
  18. package/src/cache/plugin-cache.ts +240 -0
  19. package/src/cache/policy.ts +9 -0
  20. package/src/cache/remote-lane.ts +383 -0
  21. package/src/cache/run-history.ts +92 -8
  22. package/src/cache/run-store.ts +77 -0
  23. package/src/cache/schema.ts +44 -13
  24. package/src/cache/scoped-cache.ts +22 -2
  25. package/src/cli/cache.ts +33 -16
  26. package/src/cli/completions.ts +11 -7
  27. package/src/cli/foreign-flags.ts +10 -22
  28. package/src/cli/help.ts +22 -38
  29. package/src/cli/index.ts +25 -11
  30. package/src/cli/info.ts +8 -6
  31. package/src/cli/init.ts +14 -15
  32. package/src/cli/last.ts +20 -10
  33. package/src/cli/lock.ts +39 -27
  34. package/src/cli/plan-format.ts +10 -61
  35. package/src/cli/plugin-commands.ts +77 -6
  36. package/src/cli/plugin-templates.ts +1 -1
  37. package/src/cli/run.ts +31 -191
  38. package/src/cli/select.ts +18 -9
  39. package/src/cli/show.ts +72 -40
  40. package/src/cli/task-verb.ts +4 -4
  41. package/src/cli/upgrade.ts +132 -39
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +7 -2
  44. package/src/cli/watch-judge.ts +6 -1
  45. package/src/cli/watch.ts +73 -34
  46. package/src/cli/why.ts +14 -5
  47. package/src/cli/workspace-config.ts +2 -2
  48. package/src/config.ts +0 -6
  49. package/src/exec/executor.ts +1 -1
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/index.ts +9 -1
  53. package/src/graph/priorities.ts +1 -1
  54. package/src/graph/scheduler.ts +12 -16
  55. package/src/graph/task-graph.ts +119 -21
  56. package/src/graph/task-status.ts +95 -0
  57. package/src/{orchestrator → history}/failure-mode.ts +20 -11
  58. package/src/{orchestrator → history}/history.ts +61 -5
  59. package/src/history/index.ts +48 -0
  60. package/src/{orchestrator → history}/metrics.ts +14 -23
  61. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  62. package/src/index.ts +21 -29
  63. package/src/orchestrator/affected-tasks.ts +8 -3
  64. package/src/orchestrator/doctor.ts +2 -2
  65. package/src/orchestrator/execute-args.ts +80 -0
  66. package/src/orchestrator/execute-task.ts +47 -429
  67. package/src/orchestrator/fingerprint-watch.ts +2 -2
  68. package/src/orchestrator/index.ts +3 -74
  69. package/src/orchestrator/miss-save.ts +33 -3
  70. package/src/orchestrator/options.ts +1 -36
  71. package/src/orchestrator/output-host.ts +4 -3
  72. package/src/orchestrator/persistent-task.ts +321 -0
  73. package/src/orchestrator/persistent.ts +130 -3
  74. package/src/orchestrator/placement.ts +3 -3
  75. package/src/orchestrator/plan.ts +18 -5
  76. package/src/orchestrator/plugin-host.ts +77 -47
  77. package/src/orchestrator/plugin.ts +86 -14
  78. package/src/orchestrator/prepare.ts +74 -31
  79. package/src/orchestrator/projects.ts +3 -0
  80. package/src/orchestrator/remote-prefetch.ts +23 -6
  81. package/src/orchestrator/run-artifacts.ts +5 -177
  82. package/src/orchestrator/run-context.ts +55 -0
  83. package/src/orchestrator/run-failures.ts +15 -3
  84. package/src/orchestrator/run-records.ts +36 -4
  85. package/src/orchestrator/run-report.ts +81 -179
  86. package/src/orchestrator/run-settings.ts +1 -16
  87. package/src/orchestrator/run-stop.ts +153 -0
  88. package/src/orchestrator/run.ts +313 -555
  89. package/src/orchestrator/sandbox-fix.ts +0 -16
  90. package/src/orchestrator/signals.ts +14 -6
  91. package/src/orchestrator/stable-keys.ts +107 -3
  92. package/src/orchestrator/steps.ts +50 -0
  93. package/src/orchestrator/task-hash.ts +3 -16
  94. package/src/orchestrator/task-log-buffer.ts +1 -1
  95. package/src/orchestrator/task-process.ts +94 -0
  96. package/src/orchestrator/telemetry-host.ts +1 -1
  97. package/src/orchestrator/telemetry.ts +6 -83
  98. package/src/{orchestrator → report}/events.ts +41 -8
  99. package/src/{orchestrator → report}/forecast.ts +35 -2
  100. package/src/{orchestrator → report}/framed-output.ts +39 -34
  101. package/src/report/index.ts +36 -0
  102. package/src/{orchestrator → report}/logger.ts +166 -21
  103. package/src/{orchestrator → report}/status-line.ts +84 -7
  104. package/src/{orchestrator → report}/summary.ts +179 -63
  105. package/src/report/tally.ts +83 -0
  106. package/src/settings.ts +13 -23
  107. package/src/{orchestrator → util}/colors.ts +49 -4
  108. package/src/util/config-frame.ts +31 -2
  109. package/src/util/errors.ts +32 -0
  110. package/src/util/git-ignored.ts +143 -0
  111. package/src/util/index.ts +25 -3
  112. package/src/util/refusal-notice.ts +111 -0
  113. package/src/util/report.ts +39 -0
  114. package/src/util/settle.ts +14 -39
  115. package/src/util/tail.ts +1 -1
  116. package/src/util/ui.ts +197 -0
  117. package/src/workspace/affected.ts +20 -23
  118. package/src/workspace/dependency-rules.ts +12 -4
  119. package/src/workspace/migration.ts +87 -91
  120. package/src/workspace/workspace.ts +5 -4
  121. package/schemas/summary.json +0 -133
  122. package/src/cache/chained-cache.ts +0 -276
  123. package/src/orchestrator/tally.ts +0 -112
  124. /package/src/{orchestrator → report}/path-links.ts +0 -0
  125. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  126. /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,33 +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 } from '../util/index.js'
31
- import { paint, type ColorSupport } from './colors.js'
30
+ import {
31
+ formatElapsed,
32
+ identityColor,
33
+ maskedCommand,
34
+ paint,
35
+ type ColorSupport,
36
+ } from '../util/index.js'
32
37
  import { neverStarted } from './summary.js'
33
38
  import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
34
39
  import { fileMemo, linkPaths } from './path-links.js'
35
- import { sandboxFixLine } from './sandbox-fix.js'
36
40
 
37
41
  const NO_COLOR: ColorSupport = { enabled: false }
38
42
 
39
43
  const ACCENT = '#06b6d4' // cyan-500 — bullets, remote-hit hint
40
- // Identity hues — deliberately outside the status palette (green /
41
- // red / yellow / cyan) so a task id can never read as an outcome.
42
- // Projects hash to a stable hue (same project = same color in every
43
- // run, list, and region row); tasks keep one fixed hue excluded from
44
- // 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.
45
48
  // Section rules + the summary rule share one frame width.
46
49
  const FRAME_WIDTH = 60
47
50
 
48
- const PROJECT_PALETTE = [
49
- '#a78bfa', // violet-400
50
- '#60a5fa', // blue-400
51
- '#e879f9', // fuchsia-400
52
- '#818cf8', // indigo-400
53
- '#c084fc', // purple-400
54
- '#93c5fd', // blue-300
55
- ] as const
56
- const TASK = '#f472b6' // pink-400 — task part of an id
57
51
  const SUCCESS = '#22c55e' // green-500 — success / fresh
58
52
  const WARN = '#eab308' // yellow-500 — skipped
59
53
  const ERROR = '#ef4444' // red-500 — failed
@@ -210,7 +204,8 @@ function pushOutputSection(
210
204
  function sectionLine(title: string, color: string, colors: ColorSupport): string {
211
205
  const corner = (s: string) => paint('', s, colors, { dim: true })
212
206
  const label = paint(color, title, colors, { bold: true })
213
- 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)))
214
209
  return `${corner('├─')} ${label} ${rule}`
215
210
  }
216
211
 
@@ -223,18 +218,9 @@ function pushBodyLines(lines: string[], text: string): void {
223
218
  for (const line of text.replace(/\n$/, '').split('\n')) lines.push(line)
224
219
  }
225
220
 
226
- /** Stable per-project hue: FNV-style fold of the name into the palette. */
227
- function projectColor(projectName: string): string {
228
- let h = 0
229
- for (let i = 0; i < projectName.length; i++) {
230
- h = (h * 31 + projectName.charCodeAt(i)) >>> 0
231
- }
232
- return PROJECT_PALETTE[h % PROJECT_PALETTE.length]!
233
- }
234
-
235
221
  /**
236
- * `project#task` halves in distinct identity hues (never status
237
- * 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`
238
224
  * exists for truncated display strings: the hue must hash from the
239
225
  * FULL project name so it survives truncation.
240
226
  */
@@ -243,13 +229,14 @@ export function paintIdParts(
243
229
  projectText: string,
244
230
  taskText: string,
245
231
  colors: ColorSupport,
246
- opts: { bold?: boolean } = {},
232
+ opts: { bold?: boolean; sep?: '#' | ':' } = {},
247
233
  ): string {
248
234
  const dim = (t: string) => paint('', t, colors, { dim: true })
235
+ const { sep = '#', ...style } = opts
249
236
  return (
250
- paint(projectColor(hueSource), projectText, colors, opts) +
251
- dim('#') +
252
- paint(TASK, taskText, colors, opts)
237
+ paint(identityColor(hueSource), projectText, colors, style) +
238
+ dim(sep) +
239
+ paint(identityColor(taskText), taskText, colors, style)
253
240
  )
254
241
  }
255
242
 
@@ -554,8 +541,10 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
554
541
  : []),
555
542
  '',
556
543
  ]
544
+ // vx's own notes (`vx: …`) explain a failure; they are not denials.
545
+ const denials = body.filter((l) => !l.startsWith('vx: ')).length
557
546
  return [
558
- sectionLine(`SANDBOX VIOLATIONS (${body.length})`, ERROR, colors),
547
+ sectionLine(denials > 0 ? `SANDBOX VIOLATIONS (${denials})` : 'SANDBOX', ERROR, colors),
559
548
  '',
560
549
  ...body,
561
550
  '',
@@ -705,3 +694,19 @@ function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
705
694
  return o.status
706
695
  }
707
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'
@@ -1,6 +1,5 @@
1
1
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
2
2
  import { settingEnv } from '../settings.js'
3
- import { detectColors, type ColorSupport } from './colors.js'
4
3
  import {
5
4
  formatFrameClose,
6
5
  formatFrameOpen,
@@ -18,10 +17,18 @@ import {
18
17
  createOutputWriter,
19
18
  formatFailureLine,
20
19
  formatStatusRegion,
20
+ formatStepLine,
21
21
  type StatusStream,
22
22
  type WorkerSlot,
23
23
  } from './status-line.js'
24
- import { formatSummarySection, neverStarted, 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'
25
32
  import { isGroupTask } from '../graph/index.js'
26
33
  import {
27
34
  appendTail,
@@ -30,10 +37,12 @@ import {
30
37
  resetTail,
31
38
  tailText,
32
39
  type Tail,
40
+ detectColors,
41
+ type ColorSupport,
33
42
  } from '../util/index.js'
34
- import { isCacheHit } from './telemetry.js'
43
+ import { isCacheHit } from '../graph/index.js'
35
44
  import { failedLabel, outcomeWord, ranNoCache } from './events.js'
36
- import type { Forecast } from './forecast.js'
45
+ import type { BlockedTask, Forecast } from './forecast.js'
37
46
  import {
38
47
  notifyEscape,
39
48
  PROGRESS_CLEAR,
@@ -45,6 +54,12 @@ import {
45
54
  export interface Logger {
46
55
  /** Header / footer / status text. Written verbatim, one trailing \n added. */
47
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
48
63
  /** Streamed stdout chunk for a task. Buffered until `taskComplete`. */
49
64
  taskStdout(node: TaskNode, chunk: string): void
50
65
  /** Streamed stderr chunk for a task. Buffered until `taskComplete`. */
@@ -69,8 +84,21 @@ export interface Logger {
69
84
  startedAtMs?: number
70
85
  }): void
71
86
  taskStart?(node: TaskNode): void
72
- /** Run finished (any outcome). Idempotent. */
87
+ /** The task graph finished (any outcome); vx's own closing steps follow. Idempotent. */
73
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)
74
102
  }
75
103
 
76
104
  /** The logger `run()` builds when none is passed. */
@@ -81,6 +109,12 @@ export interface DefaultLogger extends Logger {
81
109
  * throw that never reached `runEnd`.
82
110
  */
83
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
84
118
  /**
85
119
  * Hand the live region a way to predict the run's end. Called once at
86
120
  * run start; `load` runs only if the region is still live a second in,
@@ -191,6 +225,8 @@ export function resolveOutputView(
191
225
  const FORECAST_AFTER_MS = 1000
192
226
  /** A run shorter than this ends without a desktop notification. */
193
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
194
230
 
195
231
  export function defaultLogger(
196
232
  colors: ColorSupport = detectColors(),
@@ -206,9 +242,12 @@ export function defaultLogger(
206
242
  footer?: boolean
207
243
  /** The environment that names the terminal (`TERM_PROGRAM`, …); `process.env` by default. */
208
244
  env?: Readonly<Record<string, string | undefined>>
245
+ /** Where warnings go; `process.stderr` by default. */
246
+ err?: StatusStream
209
247
  } = {},
210
248
  ): DefaultLogger {
211
249
  const forwardArgs = opts.forwardArgs ?? []
250
+ const errOut = opts.err ?? process.stderr
212
251
  // Per-task buffers, split by stream. Splitting lets the framed-output
213
252
  // renderer put stdout under `├─ stdout` and stderr under `├─ stderr`.
214
253
  // The price: chunks that interleaved at runtime get re-ordered (all
@@ -302,6 +341,10 @@ export function defaultLogger(
302
341
  let critical: string | undefined
303
342
  let blocks: ReadonlyMap<string, number> | undefined
304
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)[] = []
305
348
  let etaAtMs = 0
306
349
  const signals = writer.enabled
307
350
  ? terminalSignals(opts.env ?? process.env)
@@ -329,8 +372,19 @@ export function defaultLogger(
329
372
  let upToDate = 0
330
373
  let restoredLocal = 0
331
374
  let restoredRemote = 0
375
+ let saved = 0
376
+ let notSaved = 0
332
377
  let skippedCount = 0
333
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>()
334
388
  // Cache-miss duration spread, accumulated incrementally (the same
335
389
  // numbers the final summary computes from the outcome list).
336
390
  let spreadMax = 0
@@ -406,7 +460,7 @@ export function defaultLogger(
406
460
  if (statusDead) return
407
461
  const now = Date.now()
408
462
  // Twice a second at most: one pass over the unfinished graph.
409
- if (forecastFn !== undefined && (force || now - etaAtMs >= 500)) {
463
+ if (forecastFn !== undefined && !tail && (force || now - etaAtMs >= 500)) {
410
464
  etaAtMs = now
411
465
  const running = new Map<string, number>()
412
466
  for (const s of slots) if (s !== null) running.set(s.id, s.startedMs)
@@ -416,6 +470,7 @@ export function defaultLogger(
416
470
  critical = f.critical
417
471
  blocks = f.blocks
418
472
  expected = f.expected
473
+ blocked = f.blocked
419
474
  }
420
475
  if (signals.progress && total > 0) {
421
476
  const elapsed = now - startedAtMs
@@ -434,7 +489,7 @@ export function defaultLogger(
434
489
  // The live summary IS the final summary's section, built from the
435
490
  // same formatter so the region visually becomes the printout.
436
491
  const summaryLines =
437
- opts.footer === false
492
+ opts.footer === false || !started
438
493
  ? []
439
494
  : formatSummarySection(
440
495
  {
@@ -445,11 +500,14 @@ export function defaultLogger(
445
500
  upToDate,
446
501
  restoredLocal,
447
502
  restoredRemote,
503
+ saved,
504
+ notSaved,
448
505
  miss: succeeded + failed - noCache,
449
506
  noCache,
450
507
  savedMs,
451
508
  left: total - done,
452
- ...(etaMs !== undefined ? { etaMs } : {}),
509
+ ...(etaMs !== undefined && !tail ? { etaMs } : {}),
510
+ buckets,
453
511
  spread:
454
512
  spreadCount > 0
455
513
  ? { maxMs: spreadMax, minMs: spreadMin, sumMs: spreadSum, count: spreadCount }
@@ -459,12 +517,18 @@ export function defaultLogger(
459
517
  colors,
460
518
  runContext,
461
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 })
462
524
  writer.setRegion(
463
525
  formatStatusRegion(
464
526
  {
465
527
  pinnedPersistent,
466
528
  slots: slots.slice(0, shownSlots),
529
+ ...(held !== undefined ? { blocked: held } : {}),
467
530
  overflow: slotQueue.length,
531
+ ...(shownSteps.length > 0 ? { steps: shownSteps } : {}),
468
532
  nowMs: Date.now(),
469
533
  summaryLines,
470
534
  ...(blocks !== undefined ? { blocks } : {}),
@@ -476,6 +540,46 @@ export function defaultLogger(
476
540
  { force },
477
541
  )
478
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
+ }
479
583
  const killStatus = (): void => {
480
584
  if (ticker !== null) {
481
585
  clearInterval(ticker)
@@ -499,7 +603,11 @@ export function defaultLogger(
499
603
  // no task row (`status`). Without a footer, the caller frames the log
500
604
  // (`vx watch` opens each cycle with its own line).
501
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
502
609
  const taskWrite = (chunk: string): void => {
610
+ if (chunk.length > 0) endsBlank = chunk.endsWith('\n\n')
503
611
  if (!logOpened && chunk.length > 0) {
504
612
  logOpened = true
505
613
  writer.write(`\n${chunk}`)
@@ -554,6 +662,7 @@ export function defaultLogger(
554
662
  flakyNote(outcome, colors)
555
663
 
556
664
  return {
665
+ showsSteps: writer.enabled,
557
666
  settle() {
558
667
  // The bus delivers one `run:end`, so the CLI's last word reaches here.
559
668
  flushKept()
@@ -569,28 +678,46 @@ export function defaultLogger(
569
678
  writer.write('\n')
570
679
  streamMidLine = false
571
680
  }
681
+ if (line === '' && endsBlank) return
682
+ endsBlank = line === ''
572
683
  writer.write(`${line}\n`)
573
684
  },
685
+ warn(line) {
686
+ flushKept()
687
+ if (streamMidLine) {
688
+ writer.write('\n')
689
+ streamMidLine = false
690
+ }
691
+ writer.writeTo(errOut, `${line}\n`)
692
+ },
574
693
  runStart(info) {
575
694
  total = info.total
576
695
  requestedCount = info.requestedCount ?? requestedCount
577
696
  runContext = info.context
578
697
  startedAtMs = Date.now()
698
+ started = true
579
699
  const cap = Math.max(1, Math.min(info.concurrency ?? 10, 10))
580
700
  slots = Array.from({ length: cap }, () => null)
581
701
  shownSlots = 0
582
- if (writer.enabled && !statusDead && ticker === null) {
583
- // Keeps the per-worker elapsed time ticking between task events
584
- // (the time IS the motion — there's no spinner). The writer
585
- // throttles unforced redraws, and unref means a stray ticker
586
- // can never hold the process open.
587
- ticker = setInterval(() => {
588
- refresh(false)
589
- }, 100)
590
- ticker.unref?.()
591
- }
702
+ tick()
592
703
  refresh(true)
593
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
+ },
594
721
  taskStart(node) {
595
722
  if (isGroupTask(node)) return
596
723
  if (holdsTerminal(node)) {
@@ -614,7 +741,14 @@ export function defaultLogger(
614
741
  persistentTails.set(node.id, { node, out: createTail(), err: createTail() })
615
742
  }
616
743
  const slot: WorkerSlot = { id: node.id, startedMs: Date.now() }
617
- 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)
618
752
  if (free >= 0) {
619
753
  slots[free] = slot
620
754
  shownSlots = Math.max(shownSlots, free + 1)
@@ -640,7 +774,7 @@ export function defaultLogger(
640
774
  runEnd() {
641
775
  // A long run says it ended where the user is not looking. Once:
642
776
  // the status is dead after the first runEnd.
643
- if (signals.notify && !statusDead && Date.now() - startedAtMs >= NOTIFY_AFTER_MS) {
777
+ if (signals.notify && !statusDead && !tail && Date.now() - startedAtMs >= NOTIFY_AFTER_MS) {
644
778
  out.write(
645
779
  notifyEscape(
646
780
  failed > 0
@@ -649,7 +783,14 @@ export function defaultLogger(
649
783
  ),
650
784
  )
651
785
  }
652
- 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()
653
794
  // Persistent tails first: they are context for whatever ran against
654
795
  // the server, so they read above the failures they explain. `none`
655
796
  // prints no per-task output and `errors-only` only failed tasks: a
@@ -832,10 +973,14 @@ export function defaultLogger(
832
973
  const qi = slotQueue.findIndex((s) => s.id === node.id)
833
974
  if (qi >= 0) slotQueue.splice(qi, 1)
834
975
  }
976
+ const bucket = outcome.hash === undefined ? undefined : cacheBucketOf(outcome)
977
+ if (bucket !== undefined) buckets.set(outcome.hash!, bucket)
835
978
  switch (outcome.status) {
836
979
  case 'success':
837
980
  succeeded++
838
981
  if (ranNoCache(outcome)) noCache++
982
+ if (outcome.saved === true) saved++
983
+ else if (ranUnsaved(outcome)) notSaved++
839
984
  break
840
985
  case 'cache-hit':
841
986
  if (outcome.restored === false) upToDate++