@vzn/vx 0.0.641 → 0.0.643

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 (77) hide show
  1. package/package.json +5 -12
  2. package/skills/vx/SKILL.md +4 -4
  3. package/src/cache/archive.ts +525 -104
  4. package/src/cache/cache.ts +85 -11
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +12 -3
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +4 -6
  11. package/src/cache/layered-cache.ts +96 -10
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/plugin-cache.ts +23 -16
  14. package/src/cache/remote-lane.ts +25 -8
  15. package/src/cache/run-store.ts +4 -1
  16. package/src/cache/schema.ts +13 -3
  17. package/src/cache/scoped-cache.ts +74 -237
  18. package/src/cache/umask.ts +29 -0
  19. package/src/cli/docs.ts +71 -7
  20. package/src/cli/help.ts +6 -8
  21. package/src/cli/plan-format.ts +4 -1
  22. package/src/cli/plugin-commands.ts +11 -6
  23. package/src/cli/run.ts +14 -15
  24. package/src/cli/select.ts +9 -9
  25. package/src/cli/workspace-config.ts +8 -2
  26. package/src/config.ts +7 -16
  27. package/src/exec/runner.ts +5 -0
  28. package/src/exec/sandbox-runtime.ts +81 -1
  29. package/src/graph/priorities.ts +14 -1
  30. package/src/history/history.ts +43 -33
  31. package/src/index.ts +14 -0
  32. package/src/orchestrator/affected-tasks.ts +23 -2
  33. package/src/orchestrator/deferred-outputs.ts +3 -2
  34. package/src/orchestrator/execute-task.ts +20 -14
  35. package/src/orchestrator/hit-restore.ts +10 -2
  36. package/src/orchestrator/index.ts +5 -0
  37. package/src/orchestrator/lockfile-claim.ts +54 -8
  38. package/src/orchestrator/miss-save.ts +27 -17
  39. package/src/orchestrator/options.ts +9 -1
  40. package/src/orchestrator/output-host.ts +9 -1
  41. package/src/orchestrator/plan.ts +4 -0
  42. package/src/orchestrator/plugin-host.ts +38 -4
  43. package/src/orchestrator/plugin.ts +49 -5
  44. package/src/orchestrator/prepare.ts +141 -22
  45. package/src/orchestrator/projects.ts +25 -6
  46. package/src/orchestrator/run-context.ts +80 -0
  47. package/src/orchestrator/run.ts +38 -15
  48. package/src/orchestrator/sandbox-request.ts +1 -48
  49. package/src/orchestrator/save-lane.ts +17 -10
  50. package/src/orchestrator/signals.ts +7 -10
  51. package/src/orchestrator/stable-keys.ts +83 -25
  52. package/src/orchestrator/task-hash.ts +52 -12
  53. package/src/report/events.ts +12 -0
  54. package/src/report/framed-output.ts +2 -1
  55. package/src/report/logger.ts +53 -13
  56. package/src/report/status-line.ts +45 -4
  57. package/src/report/summary.ts +8 -2
  58. package/src/settings.ts +11 -11
  59. package/src/util/errors.ts +1 -1
  60. package/src/util/hidden-prompt.ts +74 -0
  61. package/src/util/index.ts +12 -2
  62. package/src/util/paths.ts +33 -14
  63. package/src/util/secrets.ts +150 -0
  64. package/src/workspace/affected.ts +182 -75
  65. package/src/workspace/config-schema.ts +81 -19
  66. package/src/workspace/index.ts +3 -0
  67. package/src/workspace/project-loader.ts +1 -0
  68. package/src/workspace/workspace.ts +17 -11
  69. package/docs/caching.md +0 -2376
  70. package/docs/cli.md +0 -3285
  71. package/docs/execution.md +0 -676
  72. package/docs/features.md +0 -178
  73. package/docs/patterns.md +0 -219
  74. package/docs/schema.md +0 -2468
  75. package/docs/security.md +0 -253
  76. package/src/cli/docs-corpus.ts +0 -21
  77. package/src/cli/docs-text.d.ts +0 -5
@@ -12,22 +12,29 @@
12
12
  export interface SaveLane {
13
13
  /**
14
14
  * Start `save` now if the lane has room, else when one finishes. Resolves
15
- * once the save has settled — a failure is reported and resolves too —
16
- * for the one reader that must wait for the entry: a duplicate of the
17
- * same task in another run (admission's in-flight join).
15
+ * once the save has settled, true when the entry landed — a failure is
16
+ * reported and resolves false — for the readers that must know: the
17
+ * task's outcome, and a duplicate of the same task in another run
18
+ * (admission's in-flight join).
18
19
  */
19
- defer(save: () => Promise<void>): Promise<void>
20
+ defer(save: () => Promise<void>): Promise<boolean>
20
21
  }
21
22
 
22
23
  export function createSaveLane(cap: number, onError: (err: unknown) => void): SaveLane {
23
- const queue: Array<{ save: () => Promise<void>; settle: () => void }> = []
24
+ const queue: Array<{ save: () => Promise<void>; settle: (stored: boolean) => void }> = []
24
25
  let running = 0
25
- const start = (save: () => Promise<void>, settle: () => void): void => {
26
+ const start = (save: () => Promise<void>, settle: (stored: boolean) => void): void => {
26
27
  void save()
27
- .catch(onError)
28
- .then(() => {
28
+ .then(
29
+ () => true,
30
+ (err: unknown) => {
31
+ onError(err)
32
+ return false
33
+ },
34
+ )
35
+ .then((stored) => {
29
36
  running--
30
- settle()
37
+ settle(stored)
31
38
  const next = queue.shift()
32
39
  if (next !== undefined) start(next.save, next.settle)
33
40
  })
@@ -35,7 +42,7 @@ export function createSaveLane(cap: number, onError: (err: unknown) => void): Sa
35
42
  }
36
43
  return {
37
44
  defer(save) {
38
- return new Promise<void>((settle) => {
45
+ return new Promise<boolean>((settle) => {
39
46
  if (running < cap) start(save, settle)
40
47
  else queue.push({ save, settle })
41
48
  })
@@ -126,9 +126,11 @@ export function forwardSignals(args: {
126
126
  const everyChild = (): Child[] => [...args.liveChildren, ...args.persistentRegistry.values()]
127
127
  const exit = (signal: StopSignal): never => {
128
128
  // The run's own end already flushed the log; a second signal cut it
129
- // short, so the failure frames it holds print now (idempotent).
129
+ // short, so the failure frames it holds print now (idempotent), and
130
+ // the live region goes so a terminal keeps no frozen one.
130
131
  try {
131
132
  args.log.runEnd?.()
133
+ args.log.runDone?.()
132
134
  } catch {
133
135
  // teardown must not throw on the way out
134
136
  }
@@ -158,15 +160,10 @@ export function forwardSignals(args: {
158
160
  if (stopping !== undefined) exit(stopping)
159
161
  stopping = signal
160
162
  claimExitForSignal()
161
- // Clear the live worker/status region BEFORE exiting so a TTY isn't
162
- // left with a frozen region in the scrollback. Only the region: the
163
- // tasks this stop aborts still list as rows, above the failure frames
164
- // the run's own end prints.
165
- try {
166
- args.log.runDone?.()
167
- } catch {
168
- // teardown must not throw on the way out
169
- }
163
+ // The live region stays: the stop marks its rows `stopping` (run.ts's
164
+ // onStop), and the run's own end replaces it with the footer. Cleared
165
+ // at once, it left a blank screen for as long as the stop took (M,
166
+ // 2026-10-17).
170
167
  args.stop(signal)
171
168
  void settleWithin(args.done, args.boundMs)
172
169
  // The run's output may still be in the pipe: `process.exit` drops
@@ -145,6 +145,11 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
145
145
  }
146
146
  }
147
147
  const wsOutputUpstreamById = new Map<string, boolean>()
148
+ const upstreamByDeps = new Map<
149
+ string,
150
+ { outputProjects: ProjectSet; wide: ProjectSet | undefined; wsOutputUpstream: boolean }
151
+ >()
152
+ const noProjects = projects.empty()
148
153
  const stableKeys: StableKey[] = []
149
154
  // Workspace-relative project dirs, for the reach test of a
150
155
  // `workspaceFiles` reader against its upstream producers.
@@ -160,39 +165,59 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
160
165
  const upstream = synthUpstream(node, args.nodes, keyById)
161
166
 
162
167
  // Fold every dep's accumulated producers + the dep's own declared
163
- // outputs into this node's transitive-upstream producer sets.
164
- const outputProjects = projects.empty()
165
- let wide: ProjectSet | undefined
168
+ // outputs into this node's transitive-upstream producer sets. They
169
+ // depend on the deps alone, and at 5,000 projects a `^build` reader
170
+ // shares its list with its project's group: 61,600 unions of 157-word
171
+ // sets were 52 ms of a warm run, half of them repeats.
172
+ const depsKey = node.deps.length === 1 ? node.deps[0]! : node.deps.join('\0')
173
+ let up = upstreamByDeps.get(depsKey)
174
+ if (up === undefined) {
175
+ const outputs = new Union(projects)
176
+ const wideUnion = new Union(projects)
177
+ let wsOutputUpstream = false
178
+ for (const dep of node.deps) {
179
+ const depNode = args.nodes.get(dep)
180
+ if (!depNode) continue
181
+ const upstreamOfDep = outputProjectsById.get(dep)
182
+ if (upstreamOfDep !== undefined) outputs.addAll(upstreamOfDep)
183
+ const wideOfDep = wideById.get(dep)
184
+ if (wideOfDep !== undefined) wideUnion.addAll(wideOfDep)
185
+ if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
186
+ const depOut = depNode.config.cache?.outputs
187
+ if ((depOut?.files?.length ?? 0) > 0) outputs.add(depNode.projectName)
188
+ if ((depOut?.workspaceFiles?.length ?? 0) > 0) wsOutputUpstream = true
189
+ // An uncached producer declares no outputs, yet a same-project reader
190
+ // after it (`tasks: []` decouples the key, not the order) read the
191
+ // bytes it wrote: its key taken up front was the pre-producer one, and
192
+ // the fourth run of seeds A,B,B,A replayed B over A (turborepo#13788).
193
+ const reach = undeclaredWriteReach(depNode, args.workspaceRoot)
194
+ if (reach === 'project') {
195
+ outputs.add(depNode.projectName)
196
+ wideUnion.add(depNode.projectName)
197
+ } else if (reach === 'workspace') wsOutputUpstream = true
198
+ if (
199
+ depNode.config.cache === undefined &&
200
+ mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles)
201
+ )
202
+ wsOutputUpstream = true
203
+ }
204
+ up = {
205
+ outputProjects: outputs.result() ?? noProjects,
206
+ wide: wideUnion.result(),
207
+ wsOutputUpstream,
208
+ }
209
+ upstreamByDeps.set(depsKey, up)
210
+ }
211
+ const { outputProjects, wide, wsOutputUpstream } = up
166
212
  let rewriters: ProjectSet | undefined
167
213
  let unfolded: ProjectSet | undefined
168
214
  let folds: ((dep: string) => boolean) | undefined
169
215
  let wsRewriter = false
170
216
  let wsUnfoldedHere = false
171
- let wsOutputUpstream = false
172
- for (const dep of node.deps) {
217
+ for (const dep of anyUnfolded ? node.deps : []) {
173
218
  const depNode = args.nodes.get(dep)
174
219
  if (!depNode) continue
175
- const upstreamOfDep = outputProjectsById.get(dep)
176
- if (upstreamOfDep !== undefined) outputProjects.addAll(upstreamOfDep)
177
- const wideOfDep = wideById.get(dep)
178
- if (wideOfDep !== undefined) (wide ??= projects.empty()).addAll(wideOfDep)
179
- if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
180
- const depOut = depNode.config.cache?.outputs
181
- if ((depOut?.files?.length ?? 0) > 0) outputProjects.add(depNode.projectName)
182
- if ((depOut?.workspaceFiles?.length ?? 0) > 0) wsOutputUpstream = true
183
- // An uncached producer declares no outputs, yet a same-project reader
184
- // after it (`tasks: []` decouples the key, not the order) read the
185
- // bytes it wrote: its key taken up front was the pre-producer one, and
186
- // the fourth run of seeds A,B,B,A replayed B over A (turborepo#13788).
187
- const reach = undeclaredWriteReach(depNode, args.workspaceRoot)
188
- if (reach === 'project') {
189
- outputProjects.add(depNode.projectName)
190
- ;(wide ??= projects.empty()).add(depNode.projectName)
191
- } else if (reach === 'workspace') wsOutputUpstream = true
192
220
  const cached = depNode.config.cache !== undefined
193
- if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles))
194
- wsOutputUpstream = true
195
- if (!anyUnfolded) continue
196
221
  // The cached twin: a formatter rewriting its own input ahead of a
197
222
  // reader that folds no key of it replayed seed B's build under seed A
198
223
  // on the fourth run, the same way (item 750).
@@ -732,6 +757,10 @@ class ProjectSet implements ProjectNames {
732
757
  for (let w = 0; w < this.bits.length; w++) this.bits[w]! |= other.bits[w]!
733
758
  }
734
759
 
760
+ copy(): ProjectSet {
761
+ return new ProjectSet(this.index, this.bits.slice())
762
+ }
763
+
735
764
  or(other: ProjectSet): ProjectSet {
736
765
  const bits = this.bits.slice()
737
766
  for (let w = 0; w < bits.length; w++) bits[w]! |= other.bits[w]!
@@ -762,6 +791,35 @@ class ProjectSet implements ProjectNames {
762
791
  }
763
792
  }
764
793
 
794
+ /** A union that shares its first set until another one would change it. */
795
+ class Union {
796
+ private set: ProjectSet | undefined
797
+ private owned = false
798
+
799
+ constructor(private readonly index: ProjectIndex) {}
800
+
801
+ addAll(other: ProjectSet): void {
802
+ if (this.set === undefined) this.set = other
803
+ else if (this.set !== other) this.own().addAll(other)
804
+ }
805
+
806
+ add(name: string): void {
807
+ if (this.set?.has(name) !== true) this.own().add(name)
808
+ }
809
+
810
+ result(): ProjectSet | undefined {
811
+ return this.set
812
+ }
813
+
814
+ private own(): ProjectSet {
815
+ if (!this.owned) {
816
+ this.set = this.set === undefined ? this.index.empty() : this.set.copy()
817
+ this.owned = true
818
+ }
819
+ return this.set!
820
+ }
821
+ }
822
+
765
823
  function topoOrder(nodes: ReadonlyMap<string, TaskNode>): string[] {
766
824
  const indegree = new Map<string, number>()
767
825
  const dependents = new Map<string, string[]>()
@@ -1,17 +1,20 @@
1
1
  import { lstatSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import type { TaskConfig } from '../config.js'
4
- import type { ProjectFilesCache, WorkspaceFilesCache } from '../cache/index.js'
4
+ import type { FoldMemo, ProjectFilesCache, WorkspaceFilesCache } from '../cache/index.js'
5
5
  import {
6
6
  ABSENT_INPUT,
7
7
  absentOr,
8
8
  addedInput,
9
+ filesToken,
10
+ foldHead,
9
11
  type InputListing,
10
12
  type CacheKeyInput,
11
13
  type CacheLayer,
12
14
  FILE_HASH_RACY_MS,
13
15
  racyWindowMs,
14
16
  resolveInputs,
17
+ type ResolveInputsArgs,
15
18
  type GitFilesCache,
16
19
  type TaskInputComponent,
17
20
  WORKSPACE_OUTPUT_PREFIX,
@@ -41,15 +44,18 @@ export interface HashCache {
41
44
  workspaceRuntime: Map<string, Promise<string>>
42
45
  workspaceFiles: WorkspaceFilesCache
43
46
  projectFiles: ProjectFilesCache
47
+ /** Each task's last key by its probe, across runs (the local cache's `FoldMemo`). */
48
+ folds?: FoldMemo | undefined
44
49
  }
45
50
 
46
- export function createHashCache(): HashCache {
51
+ export function createHashCache(folds?: FoldMemo): HashCache {
47
52
  return {
48
53
  taskConfig: new WeakMap(),
49
54
  runtime: new Map(),
50
55
  workspaceRuntime: new Map(),
51
56
  workspaceFiles: new Map(),
52
57
  projectFiles: new Map(),
58
+ folds,
53
59
  }
54
60
  }
55
61
 
@@ -91,14 +97,39 @@ export interface ComputeHashArgs {
91
97
  */
92
98
  export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
93
99
  if (isKeyless(args.node.config)) return keylessHash(args.node.id)
100
+ const key = await taskHash(args)
101
+ args.cache.noteKey?.(args.node.id, key)
102
+ return key
103
+ }
104
+
105
+ async function taskHash(args: ComputeHashArgs): Promise<string> {
94
106
  const end = span('task hash')
95
107
  try {
96
- return await args.cache.key((await resolveKeyInput(args)).input)
108
+ const folds = args.captureInto === undefined ? args.hashCache?.folds : undefined
109
+ const token = folds === undefined ? undefined : filesToken(inputsArgs(args))
110
+ if (folds === undefined || token === undefined) {
111
+ return await args.cache.key((await resolveKeyInput(args)).input)
112
+ }
113
+ // The files fold last, so a fold up to them seeded into their token
114
+ // names the key the whole fold would: the files go unresolved.
115
+ const head = foldHead((await resolveKeyInput(args, true)).input)
116
+ const probe = xxh3hex(`${FOLD_MEMO_VERSION}\0${token}`, head)
117
+ const known = folds.get(args.node.id, probe)
118
+ if (known !== undefined) return known
119
+ const key = await args.cache.key((await resolveKeyInput(args)).input)
120
+ folds.set(args.node.id, probe, key)
121
+ return key
97
122
  } finally {
98
123
  end()
99
124
  }
100
125
  }
101
126
 
127
+ /**
128
+ * Moves every remembered fold: bump it when what a token stands for
129
+ * changes, as when `resolveFiles` picks other files from the same records.
130
+ */
131
+ const FOLD_MEMO_VERSION = 'fold-memo-v1'
132
+
102
133
  /**
103
134
  * A task that runs a command and declares no `inputs`: nothing says what
104
135
  * it read, so no key can stand for its outputs, and a key that folds it
@@ -268,19 +299,16 @@ export async function movedInput(
268
299
  return undefined
269
300
  }
270
301
 
271
- async function resolveKeyInput(
272
- args: ComputeHashArgs,
273
- ): Promise<{ input: CacheKeyInput; listings: InputListing[] }> {
302
+ /** What `resolveInputs` is asked for a task's key. */
303
+ function inputsArgs(args: ComputeHashArgs): ResolveInputsArgs {
274
304
  const cfg = args.node.config
275
305
  const cacheCfg = cfg.cache
276
- const outputs = cacheCfg?.outputs.files ?? []
277
-
278
- const resolved = await resolveInputs({
306
+ return {
279
307
  projectDir: args.node.projectDir,
280
308
  workspaceRoot: args.workspaceRoot,
281
309
  envSource: process.env,
282
310
  inputs: cfg.inputs,
283
- ownOutputs: outputs,
311
+ ownOutputs: cacheCfg?.outputs.files ?? [],
284
312
  ownWorkspaceOutputs: cacheCfg?.outputs.workspaceFiles ?? [],
285
313
  nestedProjectDirs: args.nestedProjectDirs,
286
314
  ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
@@ -293,6 +321,17 @@ async function resolveKeyInput(
293
321
  projectFilesCache: args.hashCache.projectFiles,
294
322
  }
295
323
  : {}),
324
+ }
325
+ }
326
+
327
+ async function resolveKeyInput(
328
+ args: ComputeHashArgs,
329
+ skipFiles = false,
330
+ ): Promise<{ input: CacheKeyInput; listings: InputListing[] }> {
331
+ const cfg = args.node.config
332
+ const resolved = await resolveInputs({
333
+ ...inputsArgs(args),
334
+ ...(skipFiles ? { skipFiles } : {}),
296
335
  }).catch((err: unknown) => {
297
336
  // A failed probe's message quotes its command and output, and the probe
298
337
  // runs in vx's own environment: `echo $NPM_TOKEN` printed the token in
@@ -327,8 +366,9 @@ async function resolveKeyInput(
327
366
  // in-process blob-OID computation. Tasks declaring workspaceFiles
328
367
  // also merge the workspace-wide partition's OIDs (keyed by abs
329
368
  // path, so the two maps agree wherever they overlap).
330
- let fileHashes = args.gitFilesCache?.oidsFor(args.node.projectDir)
331
- if ((cfg.inputs?.workspaceFiles?.length ?? 0) > 0) {
369
+ // A fold that stops short of the files reads no OID: asking built the map.
370
+ let fileHashes = skipFiles ? undefined : args.gitFilesCache?.oidsFor(args.node.projectDir)
371
+ if (!skipFiles && (cfg.inputs?.workspaceFiles?.length ?? 0) > 0) {
332
372
  const wsOids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
333
373
  if (wsOids !== undefined && wsOids !== fileHashes) {
334
374
  fileHashes = fileHashes === undefined ? wsOids : new Map([...wsOids, ...fileHashes])
@@ -55,6 +55,7 @@ export type RunEvent =
55
55
  | { kind: 'step:start'; id: string }
56
56
  | { kind: 'step:end'; id: string; durationMs: number; ok: boolean }
57
57
  | { kind: 'run:end' }
58
+ | { kind: 'run:stop' }
58
59
  | { kind: 'run:done' }
59
60
 
60
61
  export type RunEventSubscriber = (event: RunEvent) => void
@@ -117,6 +118,7 @@ export function busLogger(bus: EventBus): Logger {
117
118
  // run() reaches runEnd on its success path, in its finally and from a
118
119
  // signal; a plugin's onRunEnd hears the first only.
119
120
  let ended = false
121
+ let stopped = false
120
122
  let done = false
121
123
  // A status line carries plugin warnings, and a remote layer's warning
122
124
  // quotes the server's reply, which may echo what it was sent (L-40).
@@ -136,6 +138,11 @@ export function busLogger(bus: EventBus): Logger {
136
138
  },
137
139
  stepStart: (id) => bus.emit({ kind: 'step:start', id }),
138
140
  stepEnd: (id, info) => bus.emit({ kind: 'step:end', id, ...info }),
141
+ runStop: () => {
142
+ if (stopped) return
143
+ stopped = true
144
+ bus.emit({ kind: 'run:stop' })
145
+ },
139
146
  runDone: () => {
140
147
  if (done) return
141
148
  done = true
@@ -182,6 +189,9 @@ export function terminalSubscriber(sink: Logger): RunEventSubscriber {
182
189
  case 'run:end':
183
190
  sink.runEnd?.()
184
191
  return
192
+ case 'run:stop':
193
+ sink.runStop?.()
194
+ return
185
195
  case 'run:done':
186
196
  sink.runDone?.()
187
197
  return
@@ -467,6 +477,7 @@ export type WireEvent =
467
477
  | { kind: 'step:start'; id: string }
468
478
  | { kind: 'step:end'; id: string; durationMs: number; ok: boolean }
469
479
  | { kind: 'run:end' }
480
+ | { kind: 'run:stop' }
470
481
  | { kind: 'run:done' }
471
482
 
472
483
  /** Map an in-process event to its serializable wire form. */
@@ -489,6 +500,7 @@ export function toWireEvent(event: RunEvent): WireEvent {
489
500
  case 'step:start':
490
501
  case 'step:end':
491
502
  case 'run:end':
503
+ case 'run:stop':
492
504
  case 'run:done':
493
505
  return event
494
506
  }
@@ -357,7 +357,8 @@ export function formatTaskRow(
357
357
  colors: ColorSupport = NO_COLOR,
358
358
  ): string {
359
359
  const st = cell(status, statusColor, STATUS_COL, colors)
360
- const ca = cell(cache, cacheColor, CACHE_COL, colors)
360
+ // `aborting` runs one into the cache cell, which is blank beside it.
361
+ const ca = cell(cache, cacheColor, CACHE_COL - Math.max(0, status.length - STATUS_COL), colors)
361
362
  return ` ${glyph} ${timeCell(ms, colors)} ${st} ${ca} ${paintedId}`
362
363
  }
363
364
 
@@ -92,6 +92,8 @@ export interface Logger {
92
92
  */
93
93
  stepStart?(id: string): void
94
94
  stepEnd?(id: string, info: { durationMs: number; ok: boolean }): void
95
+ /** The run was stopped (a signal, an embedder's abort); its tasks are going down. Idempotent. */
96
+ runStop?(): void
95
97
  /** The run's work is done; the footer follows. Idempotent. */
96
98
  runDone?(): void
97
99
  }
@@ -116,11 +118,15 @@ export interface DefaultLogger extends Logger {
116
118
  */
117
119
  readonly showsSteps: boolean
118
120
  /**
119
- * Hand the live region a way to predict the run's end. Called once at
120
- * run start; `load` runs only if the region is still live a second in,
121
- * so a run that ends sooner never reads history for it.
121
+ * Hand the live region the run's forecast. Called once at run start:
122
+ * `graph` (the graph alone: what is blocked, and by what) is built the
123
+ * first time a worker row is free; `load` adds history's timings, and
124
+ * runs only if the region is still live a second in, so a run that ends
125
+ * sooner never reads history for it.
122
126
  */
123
- forecast(load: () => Promise<Forecast>): void
127
+ forecast(graph: () => Forecast, load: () => Promise<Forecast>): void
128
+ /** Draw no live region until the returned release: a prompt waits on the terminal. */
129
+ holdStatus(): () => void
124
130
  }
125
131
 
126
132
  /**
@@ -336,6 +342,7 @@ export function defaultLogger(
336
342
  // set is kept only where a region can show the answer.
337
343
  const finished = new Set<string>()
338
344
  let forecastFn: Forecast | undefined
345
+ let graphForecast: (() => Forecast) | undefined
339
346
  let forecastTimer: ReturnType<typeof setTimeout> | null = null
340
347
  let etaMs: number | undefined
341
348
  let critical: string | undefined
@@ -381,6 +388,12 @@ export function defaultLogger(
381
388
  // The graph is done and vx's own closing steps run: the region stays,
382
389
  // footer and step rows, until the footer replaces it (M, 2026-10-15).
383
390
  let tail = false
391
+ // The run was stopped: the region stays, its running rows say
392
+ // `aborting`, and nothing is forecast or held (M, 2026-10-17).
393
+ let stopping = false
394
+ // Counted live once the run is stopping, as the footer counts them.
395
+ let stopped = 0
396
+ let notRun = 0
384
397
  // No footer before the run starts: vx's opening steps show alone.
385
398
  let started = false
386
399
  // vx's own steps running now, by id: a row once one passes STEP_SHOWN_MS.
@@ -456,11 +469,22 @@ export function defaultLogger(
456
469
  const printsKept = (tail: { outcome?: TaskOutcome }): boolean =>
457
470
  flushedPersistent && tail.outcome !== undefined && view.mode !== 'errors-only'
458
471
 
459
- const refresh = (force: boolean): void => {
472
+ const refresh = (forced: boolean): void => {
460
473
  if (statusDead) return
474
+ let force = forced
461
475
  const now = Date.now()
476
+ // A free row shows what it holds from the first frame: the graph
477
+ // knows, without history (M, 2026-10-17).
478
+ if (forecastFn === undefined && graphForecast !== undefined && !tail && !stopping)
479
+ for (let i = 0; i < shownSlots; i++)
480
+ if (slots[i] === null) {
481
+ forecastFn = graphForecast()
482
+ graphForecast = undefined
483
+ force = true
484
+ break
485
+ }
462
486
  // Twice a second at most: one pass over the unfinished graph.
463
- if (forecastFn !== undefined && !tail && (force || now - etaAtMs >= 500)) {
487
+ if (forecastFn !== undefined && !tail && !stopping && (force || now - etaAtMs >= 500)) {
464
488
  etaAtMs = now
465
489
  const running = new Map<string, number>()
466
490
  for (const s of slots) if (s !== null) running.set(s.id, s.startedMs)
@@ -496,7 +520,9 @@ export function defaultLogger(
496
520
  failed,
497
521
  successful: succeeded + upToDate + restoredLocal + restoredRemote,
498
522
  skipped: skippedCount,
499
- total,
523
+ total: total - stopped - notRun,
524
+ ...(stopped > 0 ? { aborted: stopped } : {}),
525
+ ...(notRun > 0 ? { notRun } : {}),
500
526
  upToDate,
501
527
  restoredLocal,
502
528
  restoredRemote,
@@ -505,8 +531,8 @@ export function defaultLogger(
505
531
  miss: succeeded + failed - noCache,
506
532
  noCache,
507
533
  savedMs,
508
- left: total - done,
509
- ...(etaMs !== undefined && !tail ? { etaMs } : {}),
534
+ left: total - done - stopped - notRun,
535
+ ...(etaMs !== undefined && !tail && !stopping ? { etaMs } : {}),
510
536
  buckets,
511
537
  spread:
512
538
  spreadCount > 0
@@ -517,7 +543,7 @@ export function defaultLogger(
517
543
  colors,
518
544
  runContext,
519
545
  )
520
- const held = blocked === undefined ? undefined : holdRows(blocked)
546
+ const held = blocked === undefined || stopping ? undefined : holdRows(blocked)
521
547
  const shownSteps: WorkerSlot[] = []
522
548
  for (const [id, startedMs] of steps)
523
549
  if (now - startedMs >= STEP_SHOWN_MS) shownSteps.push({ id, startedMs })
@@ -528,6 +554,7 @@ export function defaultLogger(
528
554
  slots: slots.slice(0, shownSlots),
529
555
  ...(held !== undefined ? { blocked: held } : {}),
530
556
  overflow: slotQueue.length,
557
+ ...(stopping ? { stopping } : {}),
531
558
  ...(shownSteps.length > 0 ? { steps: shownSteps } : {}),
532
559
  nowMs: Date.now(),
533
560
  summaryLines,
@@ -714,6 +741,13 @@ export function defaultLogger(
714
741
  emitLine(formatStepLine(id, durationMs, ok, colors))
715
742
  refresh(true)
716
743
  },
744
+ runStop() {
745
+ if (stopping) return
746
+ stopping = true
747
+ blocked = undefined
748
+ heldRows.length = 0
749
+ refresh(true)
750
+ },
717
751
  runDone() {
718
752
  tail = false
719
753
  killStatus()
@@ -755,8 +789,10 @@ export function defaultLogger(
755
789
  } else slotQueue.push(slot)
756
790
  refresh(true)
757
791
  },
758
- forecast(load) {
792
+ holdStatus: () => writer.hold(),
793
+ forecast(graph, load) {
759
794
  if (statusDead || forecastTimer !== null) return
795
+ graphForecast = graph
760
796
  forecastTimer = setTimeout(() => {
761
797
  forecastTimer = null
762
798
  if (statusDead) return
@@ -898,9 +934,13 @@ export function defaultLogger(
898
934
  // a ready one starts a fresh post-ready tail below.
899
935
  persistentTails.delete(node.id)
900
936
  // An aborted task (killed by a shutdown signal, or reached by the stop
901
- // before it ran) frees its worker slot but is never counted live: the
902
- // run is tearing down and it has no honest outcome.
937
+ // before it ran) frees its worker slot and counts as the footer will
938
+ // count it: `aborted` or `not run`, beside no total.
903
939
  if (outcome.status === 'aborted') {
940
+ if (!isGroupTask(node)) {
941
+ if (neverStarted(outcome)) notRun++
942
+ else stopped++
943
+ }
904
944
  const si = slots.findIndex((s) => s !== null && s.id === node.id)
905
945
  if (si >= 0) slots[si] = slotQueue.shift() ?? null
906
946
  else {