@vzn/vx 0.0.650 → 0.0.651

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 (115) hide show
  1. package/README.md +5 -20
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/src/bin.ts +9 -31
  5. package/src/cache/cache.ts +202 -25
  6. package/src/cache/file-hashes.ts +3 -0
  7. package/src/cache/git-inputs.ts +188 -39
  8. package/src/cache/index.ts +1 -0
  9. package/src/cache/inputs.ts +3 -0
  10. package/src/cache/layer.ts +11 -0
  11. package/src/cache/layered-cache.ts +4 -0
  12. package/src/cache/linked-inputs.ts +3 -0
  13. package/src/cache/lockfile-memo.ts +3 -2
  14. package/src/cache/output-index.ts +5 -2
  15. package/src/cache/remote-lane.ts +2 -1
  16. package/src/cache/run-history.ts +11 -1
  17. package/src/cache/schema.ts +11 -1
  18. package/src/cli/cache.ts +26 -16
  19. package/src/cli/completions.ts +4 -2
  20. package/src/cli/docs.ts +10 -11
  21. package/src/cli/help.ts +12 -23
  22. package/src/cli/index.ts +4 -9
  23. package/src/cli/info.ts +15 -7
  24. package/src/cli/init.ts +23 -15
  25. package/src/cli/last.ts +213 -203
  26. package/src/cli/lock.ts +55 -5
  27. package/src/cli/plan-format.ts +100 -118
  28. package/src/cli/plugin-commands.ts +8 -2
  29. package/src/cli/plugin-templates.ts +2 -2
  30. package/src/cli/run.ts +46 -27
  31. package/src/cli/select.ts +43 -5
  32. package/src/cli/show.ts +38 -42
  33. package/src/cli/task-verb.ts +22 -0
  34. package/src/cli/upgrade.ts +3 -1
  35. package/src/cli/watch-fs.ts +6 -1
  36. package/src/cli/watch-judge.ts +2 -1
  37. package/src/cli/watch.ts +83 -44
  38. package/src/cli/why.ts +123 -89
  39. package/src/cli/workspace-config.ts +2 -1
  40. package/src/exec/executor.ts +2 -1
  41. package/src/exec/kill-tree.ts +58 -5
  42. package/src/exec/local-executor.ts +1 -1
  43. package/src/exec/pipes.ts +152 -0
  44. package/src/exec/runner.ts +199 -182
  45. package/src/exec/sandbox-binds.ts +1 -1
  46. package/src/exec/sandbox-runtime.ts +11 -2
  47. package/src/exec/spawn-off.ts +154 -0
  48. package/src/exec/warm-shell.ts +146 -0
  49. package/src/graph/critical-path.ts +40 -28
  50. package/src/graph/priorities.ts +98 -44
  51. package/src/graph/scheduler.ts +14 -10
  52. package/src/graph/task-graph.ts +27 -1
  53. package/src/handoff.ts +10 -7
  54. package/src/history/history.ts +5 -0
  55. package/src/history/metrics.ts +9 -1
  56. package/src/index.ts +3 -1
  57. package/src/orchestrator/doctor.ts +1 -1
  58. package/src/orchestrator/execute-task.ts +16 -2
  59. package/src/orchestrator/graph-memo.ts +50 -1
  60. package/src/orchestrator/graph-stage.ts +6 -0
  61. package/src/orchestrator/index.ts +3 -0
  62. package/src/orchestrator/live-usage.ts +16 -11
  63. package/src/orchestrator/locked-digests.ts +63 -0
  64. package/src/orchestrator/lockfile-claim.ts +108 -29
  65. package/src/orchestrator/miss-save.ts +31 -3
  66. package/src/orchestrator/persistent-task.ts +3 -1
  67. package/src/orchestrator/plan.ts +5 -1
  68. package/src/orchestrator/plugin-host.ts +61 -0
  69. package/src/orchestrator/plugin.ts +47 -2
  70. package/src/orchestrator/prepare.ts +16 -1
  71. package/src/orchestrator/projects.ts +4 -1
  72. package/src/orchestrator/run-context.ts +29 -10
  73. package/src/orchestrator/run-failures.ts +1 -1
  74. package/src/orchestrator/run-records.ts +10 -0
  75. package/src/orchestrator/run.ts +53 -22
  76. package/src/orchestrator/sandbox-request.ts +2 -2
  77. package/src/orchestrator/signals.ts +8 -5
  78. package/src/orchestrator/task-hash.ts +17 -3
  79. package/src/report/events.ts +3 -11
  80. package/src/report/forecast.ts +10 -32
  81. package/src/report/framed-output.ts +272 -173
  82. package/src/report/index.ts +8 -3
  83. package/src/report/logger.ts +73 -38
  84. package/src/report/plain-output.ts +1 -1
  85. package/src/report/status-line.ts +49 -357
  86. package/src/report/summary.ts +100 -131
  87. package/src/ui/ansi.ts +48 -0
  88. package/src/ui/components.ts +506 -0
  89. package/src/ui/index.ts +79 -0
  90. package/src/ui/kit.ts +37 -0
  91. package/src/ui/live-region.ts +363 -0
  92. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  93. package/src/ui/terminal.ts +51 -0
  94. package/src/ui/theme.ts +224 -0
  95. package/src/ui/version-line.ts +28 -0
  96. package/src/util/config-frame.ts +65 -10
  97. package/src/util/edit-distance.ts +6 -1
  98. package/src/util/index.ts +2 -15
  99. package/src/util/paths.ts +20 -2
  100. package/src/util/secret-mask.ts +39 -3
  101. package/src/util/settle.ts +31 -0
  102. package/src/util/timing.ts +4 -16
  103. package/src/workspace/affected.ts +89 -30
  104. package/src/workspace/config-schema.ts +4 -2
  105. package/src/workspace/dependencies-lock.ts +191 -0
  106. package/src/workspace/filter.ts +28 -0
  107. package/src/workspace/index.ts +9 -0
  108. package/src/workspace/lockfile.ts +159 -6
  109. package/src/workspace/migration.ts +4 -3
  110. package/src/workspace/workspace.ts +3 -0
  111. package/src/util/colors.ts +0 -133
  112. package/src/util/report.ts +0 -44
  113. package/src/util/ui.ts +0 -258
  114. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  115. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -33,6 +33,7 @@ import type {
33
33
  DiscoverContext,
34
34
  ExecutorContext,
35
35
  FingerprintChange,
36
+ FingerprintDigests,
36
37
  FingerprintContext,
37
38
  GraphHookContext,
38
39
  KeyHookContext,
@@ -44,6 +45,7 @@ import type {
44
45
  ConfigContext,
45
46
  } from './plugin.js'
46
47
  import type { ProjectMeta } from '../workspace/index.js'
48
+ import { hashExtra } from './lockfile-claim.js'
47
49
 
48
50
  /** `a string`, `an array`, `null`, `a number` — for a refusal that names what came back. */
49
51
  function describeValue(v: unknown): string {
@@ -392,6 +394,65 @@ export async function claimedAffected(
392
394
  ctx: FingerprintContext,
393
395
  ): Promise<Set<string> | undefined> {
394
396
  const answer = await safe(plugin, 'fingerprint', () => plugin.fingerprint!.affected(change, ctx))
397
+ return projectNames(plugin, answer, ctx)
398
+ }
399
+
400
+ /**
401
+ * `FingerprintClaim.digests`' answer for `file`, each extra file it names
402
+ * with its hash now (`hashExtra`, as a run checks it); `undefined` when the
403
+ * plugin has none. A digest of a name no project carries, or a value that
404
+ * is not a string, is refused by name: `vx lock` would commit it.
405
+ */
406
+ export async function claimedDigests(
407
+ plugin: VxPlugin,
408
+ file: string,
409
+ ctx: FingerprintContext,
410
+ ): Promise<
411
+ | {
412
+ hash: string
413
+ projects: FingerprintDigests['projects']
414
+ extras?: Readonly<Record<string, string>>
415
+ }
416
+ | undefined
417
+ > {
418
+ const digests = plugin.fingerprint?.digests
419
+ if (digests === undefined) return undefined
420
+ const answer: unknown = await safe(plugin, 'fingerprint', () =>
421
+ digests.call(plugin.fingerprint, file, ctx),
422
+ )
423
+ if (answer === undefined) return undefined
424
+ const { hash, projects, extras } = (answer ?? {}) as Partial<FingerprintDigests>
425
+ if (
426
+ typeof hash !== 'string' ||
427
+ typeof projects !== 'object' ||
428
+ projects === null ||
429
+ Object.values(projects).some((d) => typeof d !== 'string') ||
430
+ (extras !== undefined &&
431
+ (!Array.isArray(extras) || extras.some((rel) => typeof rel !== 'string')))
432
+ ) {
433
+ throw new UserError(
434
+ `plugin '${plugin.name}' failed in fingerprint: digests returned ${describeValue(answer)}, not { hash, projects: { name: digest }, extras?: [path] }`,
435
+ )
436
+ }
437
+ const known = new Set(ctx.projects.map((p) => p.name))
438
+ const stray = Object.keys(projects).find((n) => !known.has(n))
439
+ if (stray !== undefined) {
440
+ throw new UserError(
441
+ `plugin '${plugin.name}' failed in fingerprint: digest of ${JSON.stringify(stray)}, which is not a project in this workspace`,
442
+ )
443
+ }
444
+ if (extras === undefined || extras.length === 0) return { hash, projects }
445
+ const hashed = await Promise.all(
446
+ extras.map(async (rel) => [rel, await hashExtra(ctx.workspaceRoot, rel)] as const),
447
+ )
448
+ return { hash, projects, extras: Object.fromEntries(hashed) }
449
+ }
450
+
451
+ async function projectNames(
452
+ plugin: VxPlugin,
453
+ answer: unknown,
454
+ ctx: FingerprintContext,
455
+ ): Promise<Set<string> | undefined> {
395
456
  if (answer === undefined) return undefined
396
457
  // A plain object is no list: `for…of` threw "is not iterable", naming
397
458
  // neither the plugin nor the hook.
@@ -27,9 +27,9 @@ import {
27
27
  type PluginSecrets,
28
28
  SecretBroker,
29
29
  thrownMessage,
30
- ui,
31
30
  UserError,
32
31
  } from '../util/index.js'
32
+ import * as ui from '../ui/index.js'
33
33
  import type { ProjectMeta } from '../workspace/index.js'
34
34
  import type { EventBus, Logger, RunStartInfo } from '../report/index.js'
35
35
  import type { CiContext } from './run-context.js'
@@ -460,7 +460,29 @@ export interface GraphHookContext extends BaseContext {
460
460
  readonly requested: readonly string[]
461
461
  }
462
462
 
463
- export interface KeyHookContext extends BaseContext {}
463
+ export interface KeyHookContext extends BaseContext {
464
+ /**
465
+ * What `vx lock` recorded of the claimed `file` in
466
+ * `.vx/dependencies.lock.json` (`FingerprintClaim.digests`): a run keys
467
+ * from the record and never parses the file. Undefined when the file is
468
+ * absent and unrecorded. Refuses, naming `vx lock`, when the lock is
469
+ * absent or records no such present file; a recorded file whose bytes
470
+ * moved since refused the run before the key stage.
471
+ */
472
+ locked(file: string): Promise<LockedFile | undefined>
473
+ }
474
+
475
+ /** `KeyHookContext.locked`'s answer. */
476
+ export interface LockedFile {
477
+ /**
478
+ * The other files the digests read → each one's hash then (`hashExtra`).
479
+ * Not checked by core: a `link:` directory is edited with no install,
480
+ * so a claimant whose extra file moved digests anew.
481
+ */
482
+ readonly extras: Readonly<Record<string, string>>
483
+ /** The project at `projectDir`'s digest; undefined when none. Refuses for a project the lock does not list. */
484
+ digest(projectDir: string): string | undefined
485
+ }
464
486
 
465
487
  /** A plugin's claim on workspace-root files — see `VxPlugin.fingerprint`. */
466
488
  export interface FingerprintClaim {
@@ -491,6 +513,29 @@ export interface FingerprintClaim {
491
513
  bytes: Uint8Array,
492
514
  ctx: FingerprintContext,
493
515
  ): readonly string[] | Promise<readonly string[]>
516
+ /**
517
+ * Each project's digest of the claimed `file` as it is now, by project
518
+ * name, with `hash`, the xxh3 of the bytes they came from (`xxh3hex`),
519
+ * and `extras`, the other files they read. `vx lock` records them in
520
+ * `.vx/dependencies.lock.json` with each extra file's hash; a run keys
521
+ * from that record (`KeyHookContext.locked`), and `--affected`
522
+ * reads the base's copy there instead of asking `affected` to parse the
523
+ * base side, when its `hash` is the base file's xxh3. `undefined`:
524
+ * nothing to record.
525
+ */
526
+ digests?(
527
+ file: string,
528
+ ctx: FingerprintContext,
529
+ ): FingerprintDigests | undefined | Promise<FingerprintDigests | undefined>
530
+ }
531
+
532
+ /** `FingerprintClaim.digests`' answer. */
533
+ export interface FingerprintDigests {
534
+ readonly hash: string
535
+ /** Project name → its digest of the file. */
536
+ readonly projects: Readonly<Record<string, string>>
537
+ /** Root-relative files besides `file` whose bytes the digests read (a `bun.lock` patch). */
538
+ readonly extras?: readonly string[]
494
539
  }
495
540
 
496
541
  export interface FingerprintChange {
@@ -39,6 +39,7 @@ import {
39
39
  gitPathspecs,
40
40
  MAX_SCOPED_PATHSPECS,
41
41
  startGitEnumeration,
42
+ checkBlobSizes,
42
43
  lazyGitEnumeration,
43
44
  measureFsClockLag,
44
45
  DEFAULT_STORE,
@@ -52,6 +53,7 @@ import {
52
53
  lockDigest,
53
54
  PROJECT_CONFIG_FILENAMES,
54
55
  readClaimedFiles,
56
+ readDependenciesLock,
55
57
  resolveCacheDir,
56
58
  resolveStoreRoot,
57
59
  type ProjectEntry,
@@ -76,6 +78,7 @@ import {
76
78
  teardownPlugins,
77
79
  type ScheduleRun,
78
80
  } from './plugin-host.js'
81
+ import { lockedFiles } from './locked-digests.js'
79
82
  import {
80
83
  discoverProjects,
81
84
  gitOfDiscovery,
@@ -382,6 +385,16 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
382
385
  const fingerprintsAt = Date.now()
383
386
  const claimed = [...fingerprintClaims(plugins).keys()]
384
387
  const claimedBytes = await readClaimedFiles(workspaceRoot, claimed, reads)
388
+ // What `vx lock` recorded of them: a run keys from it (locked-digests.ts).
389
+ const dependenciesLock = claimed.length > 0 ? readDependenciesLock(workspaceRoot, reads) : null
390
+ dependenciesLock?.catch(() => {})
391
+ // A whole-tree run's blob-size check starts with the cache it reads its
392
+ // verdict from, beside the config load and graph build: after them, a cold
393
+ // 100-package run waited 40-50 ms on it before its first task (2026-11-03).
394
+ if (git.started !== undefined) {
395
+ localCache.holdBlobSizes()
396
+ git.started.then((e) => checkBlobSizes(e, localCache)).catch(() => {})
397
+ }
385
398
  mark('open cache')
386
399
 
387
400
  // Configs load FROM .vx/projects.lock.json with no evaluation and no check of
@@ -776,6 +789,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
776
789
  // 1,000 projects when it ran before the spawn.
777
790
  const settleIgnored = askIgnoredOutputs(gatedOutputs(nodes, workspaceRoot), workspaceRoot)
778
791
  const enumeration = await enumerating
792
+ localCache.holdBlobSizes()
779
793
  await applyGitEnumeration(
780
794
  enumeration,
781
795
  workspaceRoot,
@@ -800,7 +814,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
800
814
  }
801
815
  }
802
816
  if (hasHook(plugins, 'key')) {
803
- await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log) })
817
+ const locked = lockedFiles(workspaceRoot, claimed, claimedBytes, await dependenciesLock)
818
+ await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log), locked })
804
819
  }
805
820
  refuseFiltersThroughGroups(nodes)
806
821
  const exclude = options.excludeDependencies
@@ -370,8 +370,11 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
370
370
  }
371
371
  continue
372
372
  }
373
- const config: ProjectConfig =
373
+ const frozen: ProjectConfig =
374
374
  meta.configPath === null ? { tasks: {} } : (loaded[next++] as ProjectConfig)
375
+ // The lock shares a task two projects declare alike: an edit in place
376
+ // works on this project's own copy.
377
+ const config = projectStage ? structuredClone(frozen) : frozen
375
378
  if (projectStage) {
376
379
  // The `project` stage edits the validated object in place; core
377
380
  // re-validates after EACH plugin so a plugin can only produce what
@@ -30,7 +30,7 @@ import type { RunContextRecord } from './telemetry.js'
30
30
  export interface GitContext {
31
31
  /** `git rev-parse HEAD`, or null outside a repo / on failure. */
32
32
  commitSha: string | null
33
- /** `git rev-parse --abbrev-ref HEAD`, or null. */
33
+ /** The branch HEAD names (`refs/heads/` stripped), with or without a commit; null when detached. */
34
34
  branch: string | null
35
35
  /** True if `git status --porcelain` was non-empty; null on failure. */
36
36
  dirty: boolean | null
@@ -61,9 +61,11 @@ export interface HostContext {
61
61
 
62
62
  /**
63
63
  * One short `git` spawn per run (not per task), behind try/catch.
64
- * `git rev-parse HEAD --abbrev-ref HEAD` returns the commit on line 1
65
- * and the branch on line 2 in a SINGLE invocation — half the spawns of
66
- * two separate `rev-parse` calls. `dirty` is NOT probed here: the run's
64
+ * `git rev-parse HEAD --symbolic-full-name HEAD` returns the commit on
65
+ * line 1 and the ref on line 2 in a SINGLE invocation — half the spawns
66
+ * of two separate `rev-parse` calls. The full name, not `--abbrev-ref`:
67
+ * a tag named like the branch made that answer `heads/main`
68
+ * (vercel/turborepo#13882). `dirty` is NOT probed here: the run's
67
69
  * `GitFilesCache` populate already ran `git status --porcelain` for
68
70
  * input enumeration, so the orchestrator passes that aggregate in
69
71
  * (`dirty`) rather than paying for a second status spawn. Pass
@@ -95,7 +97,7 @@ export function captureGitContext(
95
97
  workspaceRoot,
96
98
  'rev-parse',
97
99
  'HEAD',
98
- '--abbrev-ref',
100
+ '--symbolic-full-name',
99
101
  'HEAD',
100
102
  ],
101
103
  env: gitEnv(),
@@ -107,15 +109,19 @@ export function captureGitContext(
107
109
  const sha = lines[0]?.trim()
108
110
  const br = lines[1]?.trim()
109
111
  if (sha) commitSha = sha
110
- // `--abbrev-ref HEAD` answers the literal string `HEAD` when nothing is
111
- // checked out. Recording that verbatim INVENTS a branch: every detached
112
+ // A detached HEAD answers the literal string `HEAD`, never `refs/heads/…`.
113
+ // Recording that verbatim INVENTS a branch: every detached
112
114
  // run shares a pseudo-branch "HEAD", which is how `actions/checkout`
113
115
  // leaves a pull_request build. The trust boundary (`branch ===
114
116
  // defaultBranch`) means it is at least never mistaken for trunk, but
115
117
  // every PR collapses into one scope — so one PR's timings feed another's
116
118
  // baseline, cross-branch regression detection counts them as a single
117
119
  // branch, and a consumer grouping runs by branch reads "HEAD".
118
- if (br && br !== 'HEAD') branch = br
120
+ if (br?.startsWith('refs/heads/')) branch = br.slice('refs/heads/'.length)
121
+ } else {
122
+ // No commit yet: `rev-parse HEAD` fails, yet HEAD already names the
123
+ // branch the first commit lands on (`git branch --show-current`).
124
+ branch = unbornBranch(workspaceRoot)
119
125
  }
120
126
  } catch {
121
127
  // git unavailable / non-repo: both fields stay null.
@@ -129,6 +135,18 @@ export function captureGitContext(
129
135
  return { commitSha, branch, dirty }
130
136
  }
131
137
 
138
+ /** The branch HEAD names, asked of git only after `rev-parse HEAD` failed. */
139
+ function unbornBranch(workspaceRoot: string): string | null {
140
+ const proc = Bun.spawnSync({
141
+ cmd: [executablePath('git'), '-C', workspaceRoot, 'symbolic-ref', '--quiet', 'HEAD'],
142
+ env: gitEnv(),
143
+ stdout: 'pipe',
144
+ stderr: 'ignore',
145
+ })
146
+ const ref = proc.exitCode === 0 ? new TextDecoder().decode(proc.stdout).trim() : ''
147
+ return ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : null
148
+ }
149
+
132
150
  /**
133
151
  * `HEAD`'s commit and branch from the `.git` files, or null when anything
134
152
  * about the layout is unfamiliar (the caller then spawns git). Handles a
@@ -138,7 +156,7 @@ export function captureGitContext(
138
156
  */
139
157
  function readHeadDirect(
140
158
  workspaceRoot: string,
141
- ): { commitSha: string; branch: string | null } | null {
159
+ ): { commitSha: string | null; branch: string | null } | null {
142
160
  try {
143
161
  const dirs = refDirsOf(workspaceRoot)
144
162
  if (dirs === null) return null
@@ -166,7 +184,8 @@ function readHeadDirect(
166
184
  }
167
185
  }
168
186
  }
169
- return null
187
+ // Neither a loose nor a packed ref: the branch has no commit yet.
188
+ return { commitSha: null, branch }
170
189
  } catch {
171
190
  return null
172
191
  }
@@ -8,7 +8,7 @@ import { isGroupTask, type TaskOutcome } from '../graph/index.js'
8
8
  import { decodeOutputLog } from './output-log.js'
9
9
  import { PASS_STATUSES } from '../history/index.js'
10
10
  import { maskedLine, splitTaskId } from '../util/index.js'
11
- import { fileLocations, fileMemo, type OutputLocation } from '../report/index.js'
11
+ import { fileLocations, fileMemo, type OutputLocation } from '../ui/index.js'
12
12
 
13
13
  /** A failed task of a run and what it said. */
14
14
  export interface TaskFailure {
@@ -38,6 +38,8 @@ export interface RunRecordsInput {
38
38
  host: HostContext
39
39
  /** Build the telemetry mirror; a run with no sink allocates none. */
40
40
  withTelemetry: boolean
41
+ /** Each task's count of the tasks and groups downstream of it (the scheduler's): the rows' `blocks`. */
42
+ blocks?: ReadonlyMap<string, number>
41
43
  /** The run's remote failures by key, so far (`Cache.traffic`). */
42
44
  remoteTraffic?: ReadonlyMap<string, { failures: readonly string[] }>
43
45
  }
@@ -124,6 +126,14 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
124
126
  if (o.whereCores !== undefined) r.placementCores = o.whereCores
125
127
  if (o.whereMemoryBytes !== undefined) r.placementMemoryBytes = o.whereMemoryBytes
126
128
  if (o.cpuShare !== undefined) r.cpuShare = o.cpuShare
129
+ if (o.queuedMs !== undefined) r.queuedMs = o.queuedMs
130
+ if (o.outputBytes !== undefined) r.outputBytes = o.outputBytes
131
+ const blocks = input.blocks?.get(o.node.id)
132
+ if (blocks !== undefined) r.blocks = blocks
133
+ if (o.flaky !== undefined) {
134
+ r.flakyPasses = o.flaky.passes
135
+ r.flakyFailures = o.flaky.failures
136
+ }
127
137
  if (o.wallclockStartNs !== undefined) r.wallclockStartNs = o.wallclockStartNs
128
138
  if (o.wallclockEndNs !== undefined) r.wallclockEndNs = o.wallclockEndNs
129
139
  if (hit) r.restored = o.restored === true
@@ -39,11 +39,12 @@ import {
39
39
  type TaskNode,
40
40
  type TaskOutcome,
41
41
  } from '../graph/index.js'
42
+ import { graphCounts } from './graph-memo.js'
42
43
  import {
43
44
  mark,
44
45
  stageTimes,
45
46
  MAX_TIMEOUT_MS,
46
- printTimings,
47
+ timingLines,
47
48
  ulid,
48
49
  isLiteralPattern,
49
50
  listed,
@@ -53,12 +54,17 @@ import {
53
54
  secretMask,
54
55
  isFsRefusal,
55
56
  isUserError,
56
- detectColors,
57
- type ColorSupport,
58
57
  SecretBroker,
59
58
  thrownMessage,
60
- type ui,
61
59
  } from '../util/index.js'
60
+ import * as ui from '../ui/index.js'
61
+
62
+ /** The stage table `VX_DEBUG_TIMING=1` asks for, on stderr. */
63
+ function printTimings(): void {
64
+ const lines = timingLines()
65
+ if (lines !== undefined) ui.print('err', ui.trace(lines))
66
+ }
67
+ import { detectColors, type ColorSupport } from '../ui/index.js'
62
68
  import { keyedProjects } from './keyed-projects.js'
63
69
  import { isDefaultBuild } from '../graph/index.js'
64
70
  import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
@@ -70,7 +76,6 @@ import { excludedTaint } from './excluded-keys.js'
70
76
  import { dependedOn, keyUpstream } from './upstream.js'
71
77
  import { createLiveUsage } from './live-usage.js'
72
78
  import {
73
- blocksCounts,
74
79
  busLogger,
75
80
  createEventBus,
76
81
  createForecast,
@@ -356,6 +361,11 @@ export async function run(options: RunOptions): Promise<RunSummary> {
356
361
  const detach = (): void => {
357
362
  unsubscribeTerminal()
358
363
  unsubscribeWarned()
364
+ // A run refused or thrown before its tasks (an unknown task, a plugin
365
+ // stage that threw) never reached the end that takes the live region
366
+ // down: a load slow enough to show its step left `vx:load running`
367
+ // on screen, and the CLI's refusal was written onto its last line.
368
+ terminal?.runDone?.()
359
369
  terminal?.settle()
360
370
  }
361
371
  // A plugin's `output` renders the run from its start; core's renderer
@@ -913,14 +923,39 @@ async function runOnBus(
913
923
  const fault = executorFaults(warnOf(log))
914
924
  const footers = executors.filter((e) => e.footer !== undefined)
915
925
  const placers = executors.filter((e) => e.running !== undefined)
916
- // Every row's `blocks`, the live list's and the results': one closure
917
- // over the graph, built on the first row that asks.
918
- let blocks: ReadonlyMap<string, number> | undefined
919
- const blocksNow = (): ReadonlyMap<string, number> => (blocks ??= blocksCounts(nodes))
926
+ // The scheduler's dependents count, and every row's `blocks` (the live
927
+ // list's and the results'): one closure over the graph, built on the
928
+ // first that asks.
929
+ let counted: ReadonlyMap<string, number> | undefined
930
+ const blocksNow = (): ReadonlyMap<string, number> =>
931
+ (counted ??= graphCounts(nodes, hashCache.folds, hashCache.graph))
932
+ // Every row's `usual` and the forecast's timings: history read once, on
933
+ // the first row that asks, so a run that executes nothing never reads
934
+ // it. It was read only a second into a live run, so rows before that,
935
+ // a short run's and every log's showed none (2026-10-29).
936
+ let p50s: ReadonlyMap<string, number> | undefined
937
+ const p50sNow = (): ReadonlyMap<string, number> => {
938
+ if (p50s !== undefined) return p50s
939
+ const ids: string[] = []
940
+ for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
941
+ try {
942
+ p50s = new LocalHistoryProvider(
943
+ indexDb(prepared.localCache),
944
+ undefined,
945
+ prepared.branch(),
946
+ taskDefinitions(nodes.values()),
947
+ ).p50sNow(ids)
948
+ } catch {
949
+ // History is observability: an unreadable one knows no task.
950
+ p50s = new Map()
951
+ }
952
+ return p50s
953
+ }
920
954
  // The live rows' `cpu` and `mem`; it looks only once the live list asks.
921
955
  const liveUsage = createLiveUsage()
922
956
  const runContext = {
923
957
  blocks: blocksNow,
958
+ usual: (id: string) => p50sNow().get(id),
924
959
  usage: liveUsage.usage,
925
960
  ...(footers.length > 0
926
961
  ? { executorRows: () => footers.flatMap((e) => executorFooter(e, fault)) }
@@ -979,17 +1014,7 @@ async function runOnBus(
979
1014
  // never reads history.
980
1015
  terminal?.forecast(
981
1016
  () => createForecast(nodes, new Map(), concurrency, blocksNow()),
982
- async () => {
983
- const ids: string[] = []
984
- for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
985
- const p50s = await new LocalHistoryProvider(
986
- indexDb(prepared.localCache),
987
- undefined,
988
- prepared.branch(),
989
- taskDefinitions(nodes.values()),
990
- ).p50sFor(ids)
991
- return createForecast(nodes, p50s, concurrency, blocksNow())
992
- },
1017
+ async () => createForecast(nodes, p50sNow(), concurrency, blocksNow()),
993
1018
  )
994
1019
 
995
1020
  // Remote-only: kick off background prefetches so remote-GET latency
@@ -1268,10 +1293,12 @@ async function runOnBus(
1268
1293
  }
1269
1294
  }
1270
1295
  let lockBroken: boolean | undefined
1296
+ prepared.localCache.storeHeldBlobSizes()
1271
1297
  const outcomes = await runGraph({
1272
1298
  nodes,
1273
1299
  concurrency,
1274
1300
  restoreConcurrency: options.restoreConcurrency ?? 2 * concurrency,
1301
+ reverseDeps: blocksNow,
1275
1302
  settledOf: (o) => deferredSaves.get(o.node.id),
1276
1303
  ...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
1277
1304
  ...(admit !== undefined ? { admit } : {}),
@@ -1280,8 +1307,8 @@ async function runOnBus(
1280
1307
  serverDied,
1281
1308
  onFailFast: () => failFast.abort(),
1282
1309
  signal: stop.signal,
1283
- onStart: (node, queuedMs) => {
1284
- log.taskStart?.(node, queuedMs === undefined ? undefined : { waitedMs: queuedMs })
1310
+ onStart: (node) => {
1311
+ log.taskStart?.(node)
1285
1312
  },
1286
1313
  onFinish: (o) => {
1287
1314
  if (cacheOff) o.cacheOff = true
@@ -1380,6 +1407,9 @@ async function runOnBus(
1380
1407
  const hold =
1381
1408
  options.holdPersistent === true &&
1382
1409
  (!stop.signal.aborted || stop.signal.reason === CYCLE_INTERRUPT)
1410
+ // A stop already signalled every server once and waits out its grace:
1411
+ // a SIGTERM here, while a server ran its cleanup, cut it short.
1412
+ if (stop.signal.aborted) await stop.aborting?.catch(() => {})
1383
1413
  const { keepAlive, crashed: crashedPersistent } = await disposeServers({
1384
1414
  registry: persistentRegistry,
1385
1415
  nodes,
@@ -1488,6 +1518,7 @@ async function runOnBus(
1488
1518
  ci: ciContext,
1489
1519
  host: hostContext,
1490
1520
  withTelemetry: telemetry !== undefined,
1521
+ blocks: blocksNow(),
1491
1522
  remoteTraffic: prepared.localCache.traffic.keys,
1492
1523
  })
1493
1524
  const records = recordsOf(list, ok)
@@ -446,8 +446,8 @@ export function withheldLinkLine(taskId: string, w: WithheldLink): string {
446
446
  return (
447
447
  `vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key includes no task of ` +
448
448
  `${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
449
- `reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
450
- `\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
449
+ `reaches one (\`^build\`, where ${w.name}#build keys its sources), or grant and key ` +
450
+ `the files yourself (\`allow.read\` plus ` +
451
451
  `\`inputs.workspaceFiles\`).`
452
452
  )
453
453
  }
@@ -14,7 +14,13 @@
14
14
  // them to the runner around every spawn.
15
15
 
16
16
  import { signalExitCode } from '../exec/index.js'
17
- import { claimExitForSignal, hangupIgnored, killGraceMs, settleWithin } from '../util/index.js'
17
+ import {
18
+ claimExitForSignal,
19
+ dieOf,
20
+ hangupIgnored,
21
+ killGraceMs,
22
+ settleWithin,
23
+ } from '../util/index.js'
18
24
  import {
19
25
  holdGroups,
20
26
  killTree,
@@ -154,10 +160,7 @@ export function forwardSignals(args: {
154
160
  // and a 130 exit ran the script's next line. The code stays the
155
161
  // fallback should the signal not land.
156
162
  if (args.handsTerminal !== true) {
157
- process.once('exit', () => {
158
- process.removeAllListeners(signal)
159
- process.kill(process.pid, signal)
160
- })
163
+ process.once('exit', () => dieOf(signal))
161
164
  }
162
165
  process.exit(signalExitCode(signal))
163
166
  }
@@ -519,9 +519,23 @@ function hashableConfig(cfg: TaskConfig): unknown {
519
519
  // Keys sorted: moving `cache` above `exec`, or a `define` in another
520
520
  // order, missed every key of the task, and a `project` or `graph` plugin
521
521
  // writes a config the lock never sorted (2026-10-25).
522
- if (cfg.exec?.remote === undefined) return keysSorted(cfg)
523
- const { remote: _remote, ...execRest } = cfg.exec
524
- return keysSorted({ ...cfg, exec: execRest })
522
+ const env = cfg.inputs?.env
523
+ const pass = cfg.exec?.env?.passThrough
524
+ if (cfg.exec?.remote === undefined && sortedNames(env) && sortedNames(pass))
525
+ return keysSorted(cfg)
526
+ const { remote: _remote, ...execRest } = cfg.exec ?? ({} as NonNullable<TaskConfig['exec']>)
527
+ // Env names are sets: each value is read by its name, so their order
528
+ // keys nothing (conformance env/declared-order-irrelevant).
529
+ if (!sortedNames(pass)) execRest.env = { ...execRest.env, passThrough: [...pass!].sort() }
530
+ const out: TaskConfig = cfg.exec === undefined ? { ...cfg } : { ...cfg, exec: execRest }
531
+ if (!sortedNames(env)) out.inputs = { ...cfg.inputs!, env: [...env!].sort() }
532
+ return keysSorted(out)
533
+ }
534
+
535
+ function sortedNames(names: readonly string[] | undefined): boolean {
536
+ if (names === undefined) return true
537
+ for (let i = 1; i < names.length; i++) if (names[i - 1]! > names[i]!) return false
538
+ return true
525
539
  }
526
540
 
527
541
  /**
@@ -45,7 +45,7 @@ export interface RunStartInfo {
45
45
  */
46
46
  export type RunEvent =
47
47
  | { kind: 'run:start'; info: RunStartInfo }
48
- | { kind: 'task:start'; node: TaskNode; waitedMs?: number }
48
+ | { kind: 'task:start'; node: TaskNode }
49
49
  | { kind: 'task:stdout'; node: TaskNode; chunk: string }
50
50
  | { kind: 'task:stderr'; node: TaskNode; chunk: string }
51
51
  | { kind: 'task:complete'; node: TaskNode; outcome: TaskOutcome }
@@ -139,12 +139,7 @@ export function busLogger(bus: EventBus): Logger {
139
139
  taskStderr: (node, chunk) => bus.emit({ kind: 'task:stderr', node, chunk }),
140
140
  taskComplete: (node, outcome) => bus.emit({ kind: 'task:complete', node, outcome }),
141
141
  runStart: (info) => bus.emit({ kind: 'run:start', info }),
142
- taskStart: (node, info) =>
143
- bus.emit(
144
- info?.waitedMs === undefined
145
- ? { kind: 'task:start', node }
146
- : { kind: 'task:start', node, waitedMs: info.waitedMs },
147
- ),
142
+ taskStart: (node) => bus.emit({ kind: 'task:start', node }),
148
143
  runEnd: () => {
149
144
  if (ended) return
150
145
  ended = true
@@ -179,10 +174,7 @@ export function terminalSubscriber(sink: Logger): RunEventSubscriber {
179
174
  sink.runStart?.(event.info)
180
175
  return
181
176
  case 'task:start':
182
- sink.taskStart?.(
183
- event.node,
184
- event.waitedMs === undefined ? undefined : { waitedMs: event.waitedMs },
185
- )
177
+ sink.taskStart?.(event.node)
186
178
  return
187
179
  case 'task:stdout':
188
180
  sink.taskStdout(event.node, event.chunk)