@vzn/vx 0.0.640 → 0.0.642

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 (76) hide show
  1. package/launcher.sh +9 -2
  2. package/package.json +5 -12
  3. package/skills/vx/SKILL.md +4 -4
  4. package/src/bin.ts +8 -6
  5. package/src/cache/archive.ts +735 -107
  6. package/src/cache/cache.ts +133 -29
  7. package/src/cache/fold-memo.ts +58 -0
  8. package/src/cache/git-inputs.ts +213 -31
  9. package/src/cache/index.ts +9 -2
  10. package/src/cache/inputs.ts +244 -108
  11. package/src/cache/key-fold.ts +90 -69
  12. package/src/cache/layer.ts +9 -7
  13. package/src/cache/layered-cache.ts +74 -9
  14. package/src/cache/output-index.ts +25 -4
  15. package/src/cache/remote-lane.ts +27 -10
  16. package/src/cache/schema.ts +13 -3
  17. package/src/cache/scoped-cache.ts +11 -3
  18. package/src/cache/umask.ts +29 -0
  19. package/src/cli/docs.ts +71 -7
  20. package/src/cli/help.ts +11 -5
  21. package/src/cli/plan-format.ts +4 -1
  22. package/src/cli/run.ts +10 -3
  23. package/src/cli/select.ts +2 -0
  24. package/src/cli/upgrade.ts +37 -2
  25. package/src/cli/watch-filter.ts +1 -6
  26. package/src/cli/watch-fs.ts +2 -1
  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/handoff.ts +5 -0
  31. package/src/history/history.ts +43 -33
  32. package/src/index.ts +11 -1
  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 +111 -29
  36. package/src/orchestrator/index.ts +1 -0
  37. package/src/orchestrator/local-shortcircuit.ts +8 -0
  38. package/src/orchestrator/lockfile-claim.ts +54 -8
  39. package/src/orchestrator/miss-save.ts +27 -17
  40. package/src/orchestrator/output-host.ts +9 -1
  41. package/src/orchestrator/plan.ts +4 -0
  42. package/src/orchestrator/plugin-host.ts +35 -5
  43. package/src/orchestrator/plugin.ts +29 -3
  44. package/src/orchestrator/prepare.ts +74 -5
  45. package/src/orchestrator/projects.ts +6 -1
  46. package/src/orchestrator/run-report.ts +30 -9
  47. package/src/orchestrator/run.ts +62 -38
  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 +46 -12
  53. package/src/report/events.ts +12 -0
  54. package/src/report/framed-output.ts +2 -1
  55. package/src/report/logger.ts +58 -15
  56. package/src/report/status-line.ts +45 -4
  57. package/src/report/summary.ts +11 -6
  58. package/src/util/errors.ts +1 -1
  59. package/src/util/hidden-prompt.ts +74 -0
  60. package/src/util/index.ts +13 -0
  61. package/src/util/paths.ts +58 -5
  62. package/src/util/secrets.ts +150 -0
  63. package/src/workspace/affected.ts +63 -13
  64. package/src/workspace/config-eval.ts +2 -2
  65. package/src/workspace/config-schema.ts +37 -3
  66. package/src/workspace/project-loader.ts +6 -4
  67. package/src/workspace/workspace.ts +17 -11
  68. package/docs/caching.md +0 -2352
  69. package/docs/cli.md +0 -3280
  70. package/docs/execution.md +0 -675
  71. package/docs/features.md +0 -178
  72. package/docs/patterns.md +0 -219
  73. package/docs/schema.md +0 -2461
  74. package/docs/security.md +0 -252
  75. package/src/cli/docs-corpus.ts +0 -21
  76. package/src/cli/docs-text.d.ts +0 -5
@@ -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
 
@@ -93,12 +99,31 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
93
99
  if (isKeyless(args.node.config)) return keylessHash(args.node.id)
94
100
  const end = span('task hash')
95
101
  try {
96
- return await args.cache.key((await resolveKeyInput(args)).input)
102
+ const folds = args.captureInto === undefined ? args.hashCache?.folds : undefined
103
+ const token = folds === undefined ? undefined : filesToken(inputsArgs(args))
104
+ if (folds === undefined || token === undefined) {
105
+ return await args.cache.key((await resolveKeyInput(args)).input)
106
+ }
107
+ // The files fold last, so a fold up to them seeded into their token
108
+ // names the key the whole fold would: the files go unresolved.
109
+ const head = foldHead((await resolveKeyInput(args, true)).input)
110
+ const probe = xxh3hex(`${FOLD_MEMO_VERSION}\0${token}`, head)
111
+ const known = folds.get(args.node.id, probe)
112
+ if (known !== undefined) return known
113
+ const key = await args.cache.key((await resolveKeyInput(args)).input)
114
+ folds.set(args.node.id, probe, key)
115
+ return key
97
116
  } finally {
98
117
  end()
99
118
  }
100
119
  }
101
120
 
121
+ /**
122
+ * Moves every remembered fold: bump it when what a token stands for
123
+ * changes, as when `resolveFiles` picks other files from the same records.
124
+ */
125
+ const FOLD_MEMO_VERSION = 'fold-memo-v1'
126
+
102
127
  /**
103
128
  * A task that runs a command and declares no `inputs`: nothing says what
104
129
  * it read, so no key can stand for its outputs, and a key that folds it
@@ -268,19 +293,16 @@ export async function movedInput(
268
293
  return undefined
269
294
  }
270
295
 
271
- async function resolveKeyInput(
272
- args: ComputeHashArgs,
273
- ): Promise<{ input: CacheKeyInput; listings: InputListing[] }> {
296
+ /** What `resolveInputs` is asked for a task's key. */
297
+ function inputsArgs(args: ComputeHashArgs): ResolveInputsArgs {
274
298
  const cfg = args.node.config
275
299
  const cacheCfg = cfg.cache
276
- const outputs = cacheCfg?.outputs.files ?? []
277
-
278
- const resolved = await resolveInputs({
300
+ return {
279
301
  projectDir: args.node.projectDir,
280
302
  workspaceRoot: args.workspaceRoot,
281
303
  envSource: process.env,
282
304
  inputs: cfg.inputs,
283
- ownOutputs: outputs,
305
+ ownOutputs: cacheCfg?.outputs.files ?? [],
284
306
  ownWorkspaceOutputs: cacheCfg?.outputs.workspaceFiles ?? [],
285
307
  nestedProjectDirs: args.nestedProjectDirs,
286
308
  ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
@@ -293,6 +315,17 @@ async function resolveKeyInput(
293
315
  projectFilesCache: args.hashCache.projectFiles,
294
316
  }
295
317
  : {}),
318
+ }
319
+ }
320
+
321
+ async function resolveKeyInput(
322
+ args: ComputeHashArgs,
323
+ skipFiles = false,
324
+ ): Promise<{ input: CacheKeyInput; listings: InputListing[] }> {
325
+ const cfg = args.node.config
326
+ const resolved = await resolveInputs({
327
+ ...inputsArgs(args),
328
+ ...(skipFiles ? { skipFiles } : {}),
296
329
  }).catch((err: unknown) => {
297
330
  // A failed probe's message quotes its command and output, and the probe
298
331
  // runs in vx's own environment: `echo $NPM_TOKEN` printed the token in
@@ -327,8 +360,9 @@ async function resolveKeyInput(
327
360
  // in-process blob-OID computation. Tasks declaring workspaceFiles
328
361
  // also merge the workspace-wide partition's OIDs (keyed by abs
329
362
  // 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) {
363
+ // A fold that stops short of the files reads no OID: asking built the map.
364
+ let fileHashes = skipFiles ? undefined : args.gitFilesCache?.oidsFor(args.node.projectDir)
365
+ if (!skipFiles && (cfg.inputs?.workspaceFiles?.length ?? 0) > 0) {
332
366
  const wsOids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
333
367
  if (wsOids !== undefined && wsOids !== fileHashes) {
334
368
  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 {
@@ -973,8 +1013,11 @@ export function defaultLogger(
973
1013
  const qi = slotQueue.findIndex((s) => s.id === node.id)
974
1014
  if (qi >= 0) slotQueue.splice(qi, 1)
975
1015
  }
976
- const bucket = outcome.hash === undefined ? undefined : cacheBucketOf(outcome)
977
- if (bucket !== undefined) buckets.set(outcome.hash!, bucket)
1016
+ const bucket = cacheBucketOf(outcome)
1017
+ if (bucket !== undefined) {
1018
+ if (outcome.hash !== undefined) buckets.set(outcome.hash, bucket)
1019
+ buckets.set(node.id, bucket)
1020
+ }
978
1021
  switch (outcome.status) {
979
1022
  case 'success':
980
1023
  succeeded++
@@ -56,6 +56,11 @@ export interface OutputWriter {
56
56
  settle(): void
57
57
  /** Permanently remove the status display; later set* is a no-op. */
58
58
  clearStatus(): void
59
+ /**
60
+ * Erase the status display and draw none until the returned release:
61
+ * a prompt on the terminal (a secret's) waits for its answer there.
62
+ */
63
+ hold(): () => void
59
64
  }
60
65
 
61
66
  export interface OutputWriterOptions {
@@ -108,6 +113,7 @@ export function createOutputWriter(
108
113
  // `regionRows`.
109
114
  let shownRows = 0
110
115
  let dead = false
116
+ let holds = 0
111
117
  let lastDraw = -Infinity
112
118
  // Trailing draw scheduled when a forced set lands inside the floor.
113
119
  let trailing: ReturnType<typeof setTimeout> | null = null
@@ -196,7 +202,7 @@ export function createOutputWriter(
196
202
  const set = (lines: readonly string[], o: { force?: boolean }): void => {
197
203
  if (!enabled || dead) return
198
204
  current = lines
199
- if (!atLineStart) return
205
+ if (!atLineStart || holds > 0) return
200
206
  const since = now() - lastDraw
201
207
  if (o.force) {
202
208
  if (since >= forceFloorMs) {
@@ -209,7 +215,7 @@ export function createOutputWriter(
209
215
  if (trailing === null) {
210
216
  trailing = setTimeout(() => {
211
217
  trailing = null
212
- if (dead || current === null || !atLineStart) return
218
+ if (dead || current === null || !atLineStart || holds > 0) return
213
219
  draw()
214
220
  }, forceFloorMs - since)
215
221
  trailing.unref?.()
@@ -241,7 +247,7 @@ export function createOutputWriter(
241
247
  }
242
248
  stream.write(chunk)
243
249
  if (chunk.length > 0) atLineStart = chunk.endsWith('\n')
244
- if (!dead && current !== null && atLineStart) draw()
250
+ if (!dead && current !== null && atLineStart && holds === 0) draw()
245
251
  },
246
252
  writeTo(target, chunk) {
247
253
  if (target === stream) return this.write(chunk)
@@ -257,7 +263,7 @@ export function createOutputWriter(
257
263
  shownRows = 0
258
264
  }
259
265
  target.write(chunk)
260
- if (!dead && current !== null && atLineStart) draw()
266
+ if (!dead && current !== null && atLineStart && holds === 0) draw()
261
267
  },
262
268
  setStatus(line, o = {}) {
263
269
  set([line], o)
@@ -280,6 +286,23 @@ export function createOutputWriter(
280
286
  shownRows = 0
281
287
  }
282
288
  },
289
+ hold() {
290
+ if (!enabled || dead) return () => undefined
291
+ holds++
292
+ cancelTrailing()
293
+ if (shown) {
294
+ stream.write(eraseSeq())
295
+ shown = false
296
+ shownRows = 0
297
+ }
298
+ let released = false
299
+ return () => {
300
+ if (released) return
301
+ released = true
302
+ holds--
303
+ if (holds === 0 && !dead && current !== null && atLineStart) draw()
304
+ }
305
+ },
283
306
  }
284
307
  }
285
308
 
@@ -310,6 +333,8 @@ export interface StatusRegionState {
310
333
  blocked?: readonly (BlockedTask | null)[]
311
334
  /** Running tasks beyond the displayed slots. */
312
335
  overflow: number
336
+ /** The run was stopped: running rows say `aborting`, idle rows leave. */
337
+ stopping?: boolean
313
338
  /**
314
339
  * vx's own steps still running, shown below the worker rows: no worker
315
340
  * slot or `--concurrency` bounds them (M, 2026-10-15).
@@ -428,6 +453,7 @@ export function formatStatusRegion(
428
453
  // One task running has no path to compare against.
429
454
  const busy = s.slots.filter((x) => x !== null).length
430
455
  for (const [row, slot] of s.slots.entries()) {
456
+ if (slot === null && s.stopping === true) continue
431
457
  if (slot === null) {
432
458
  const w = s.blocked?.[row]
433
459
  lines.push(
@@ -440,6 +466,21 @@ export function formatStatusRegion(
440
466
  }
441
467
  // What waits on it, after the id: the row to watch is the one the
442
468
  // longest chain waits on, and the one the most tasks wait on.
469
+ if (s.stopping === true) {
470
+ lines.push(
471
+ formatTaskRow(
472
+ ' ',
473
+ Math.max(0, s.nowMs - slot.startedMs),
474
+ 'aborting',
475
+ WARN,
476
+ '',
477
+ '',
478
+ paintPinnedId(slot.id, colors),
479
+ colors,
480
+ ),
481
+ )
482
+ continue
483
+ }
443
484
  const waiting = s.blocks?.get(slot.id) ?? 0
444
485
  const notes: string[] = []
445
486
  if (s.critical === slot.id && busy > 1) notes.push(paint(WARN, 'critical path', colors))