@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -15,10 +15,17 @@ import type { GitFilesCache } from '../cache/index.js'
15
15
  import type { TaskNode } from '../graph/index.js'
16
16
  import { xxh3hex } from '../util/index.js'
17
17
  import type { StableKey } from './stable-keys.js'
18
- import { hashTaskConfig, isKeyless, taskFilesToken, type HashCache } from './task-hash.js'
18
+ import {
19
+ hashTaskConfig,
20
+ inputStrategiesOf,
21
+ isKeyless,
22
+ taskFilesToken,
23
+ workspaceCleanOf,
24
+ type HashCache,
25
+ } from './task-hash.js'
19
26
 
20
27
  /** Moves every remembered run: bump it when what a token stands for changes. */
21
- const RUN_MEMO_VERSION = 'run-memo-v1'
28
+ const RUN_MEMO_VERSION = 'run-memo-v2'
22
29
 
23
30
  export interface RunKeysArgs {
24
31
  nodes: Map<string, TaskNode>
@@ -64,6 +71,11 @@ export function runKeysToken(args: RunKeysArgs): RunKeysAt | undefined {
64
71
  JSON.stringify([...args.nestedDirsByProject]),
65
72
  // Which outputs git ignores decides which a reader may see (`readableOutputs`).
66
73
  [...args.gitFilesCache.ignoredOutputs].sort().join('\0'),
74
+ // Folded into a config's key; a remembered graph's token names the
75
+ // lock, not `.vx/workspace` or the variables.
76
+ String(workspaceCleanOf(args.hashCache)),
77
+ String(inputStrategiesOf(args.hashCache)?.run),
78
+ String(inputStrategiesOf(args.hashCache)?.workspace),
67
79
  ]
68
80
  // A remembered graph's token names every node's edges and config, so
69
81
  // each node adds only what may differ under it (4 of 8 ms at 1,206
@@ -75,7 +87,20 @@ export function runKeysToken(args: RunKeysArgs): RunKeysAt | undefined {
75
87
  // Tasks of one project mostly declare the same files and outputs, and the
76
88
  // token is a function of that declaration (`filesToken`): one walk each,
77
89
  // not one per task (5 of 12 ms at 1,206 tasks, 2026-10-23).
78
- const tokens = new Map<string, string>()
90
+ // Keyed by the project, then by the declaration's text, and that text is
91
+ // taken once per config object: the lock shares a config among the tasks
92
+ // that declare it alike, and a stringify and a joined key per node were
93
+ // ~6 ms of 9,603 (2026-11-07).
94
+ const tokens = new Map<string, Map<string, string>>()
95
+ const declarations = new Map<TaskNode['config'], string>()
96
+ const declarationOf = (cfg: TaskNode['config']): string => {
97
+ let text = declarations.get(cfg)
98
+ if (text === undefined) {
99
+ text = JSON.stringify([cfg.inputs?.files, cfg.outputs?.files, cfg.outputs?.workspaceFiles])
100
+ declarations.set(cfg, text)
101
+ }
102
+ return text
103
+ }
79
104
  const filesAt: number[] = []
80
105
  for (const node of args.nodes.values()) {
81
106
  const cfg = node.config
@@ -84,24 +109,29 @@ export function runKeysToken(args: RunKeysArgs): RunKeysAt | undefined {
84
109
  if ((inputs?.runtime?.length ?? 0) > 0 || (inputs?.workspaceRuntime?.length ?? 0) > 0) {
85
110
  return undefined
86
111
  }
112
+ // A files token in full is a root, a declaration and a digest, ~200
113
+ // characters per node, joined and hashed again: 2 MB of token at 8,002
114
+ // tasks (2026-11-07). Each distinct one is hashed once instead, and the
115
+ // node names that hash, which is also what the delta compares.
87
116
  let files = ''
88
117
  if (inputs !== undefined) {
89
118
  if ((inputs.workspaceFiles?.length ?? 0) > 0) return undefined
90
119
  // No positive entry resolves no files (`filesPlan`).
91
120
  if (inputs.files === undefined || inputs.files.some((e) => !e.startsWith('!'))) {
92
- const outputs = cfg.cache?.outputs
93
- const declared = `${node.projectName}\0${JSON.stringify([inputs.files, outputs?.files, outputs?.workspaceFiles])}`
94
- let token = tokens.get(declared)
121
+ const declared = declarationOf(cfg)
122
+ let byDeclared = tokens.get(node.projectName)
123
+ if (byDeclared === undefined) tokens.set(node.projectName, (byDeclared = new Map()))
124
+ let token = byDeclared.get(declared)
95
125
  if (token === undefined) {
96
- token = taskFilesToken({
126
+ const full = taskFilesToken({
97
127
  node,
98
128
  workspaceRoot: args.workspaceRoot,
99
129
  nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
100
130
  gitFilesCache: args.gitFilesCache,
101
- hashCache: args.hashCache,
102
131
  })
103
- if (token === undefined) return undefined
104
- tokens.set(declared, token)
132
+ if (full === undefined) return undefined
133
+ token = xxh3hex(full)
134
+ byDeclared.set(declared, token)
105
135
  }
106
136
  files = token
107
137
  }
@@ -139,13 +169,9 @@ export function runKeysToken(args: RunKeysArgs): RunKeysAt | undefined {
139
169
  at.delta = () => {
140
170
  if (delta !== undefined) return delta
141
171
  const files = new Map<string, string>()
142
- const hashed = new Map<string, string>()
143
172
  const rest = [...parts]
144
173
  filesAt.forEach((i, n) => {
145
- const token = parts[i]!
146
- let h = hashed.get(token)
147
- if (h === undefined) hashed.set(token, (h = xxh3hex(token)))
148
- files.set(ids[n]!, h)
174
+ files.set(ids[n]!, parts[i]!)
149
175
  rest[i] = ''
150
176
  })
151
177
  return (delta = { global: `${selection}\0${xxh3hex(rest.join('\0'))}`, files })
@@ -154,6 +180,21 @@ export function runKeysToken(args: RunKeysArgs): RunKeysAt | undefined {
154
180
  return at
155
181
  }
156
182
 
183
+ /**
184
+ * A memo row: each stable and uncached key with its node's place in the
185
+ * run's node order. The token names every node id in that order, so a row
186
+ * read under it means the same nodes; by id, the row's JSON and a lookup
187
+ * per node were ~5 ms of 8,002 (2026-11-07).
188
+ */
189
+ interface KeysRow {
190
+ /** Stable keys: node places, and their keys alike. */
191
+ s: number[]
192
+ h: string[]
193
+ /** Uncached keys: node places, and their keys alike. */
194
+ u: number[]
195
+ k: string[]
196
+ }
197
+
157
198
  /** The keys remembered under `token`, mapped back onto this run's nodes. */
158
199
  export function readRunKeys(
159
200
  args: RunKeysArgs,
@@ -161,19 +202,28 @@ export function readRunKeys(
161
202
  ): RunKeys | undefined {
162
203
  const row = args.hashCache.folds?.get(at.id, at.token)
163
204
  if (row === undefined) return undefined
164
- let parsed: { s: [string, string][]; u: [string, string][] }
205
+ let parsed: KeysRow
165
206
  try {
166
207
  parsed = JSON.parse(row)
167
208
  } catch {
168
209
  return undefined
169
210
  }
211
+ const { s, h, u, k } = parsed
212
+ if (s?.length !== h?.length || u?.length !== k?.length) return undefined
213
+ const nodes = [...args.nodes.values()]
170
214
  const stableKeys: StableKey[] = []
171
- for (const [id, hash] of parsed.s) {
172
- const node = args.nodes.get(id)
215
+ for (let i = 0; i < s.length; i++) {
216
+ const node = nodes[s[i]!]
217
+ if (node === undefined) return undefined
218
+ stableKeys.push({ hash: h[i]!, node })
219
+ }
220
+ const uncachedKeys = new Map<string, string>()
221
+ for (let i = 0; i < u.length; i++) {
222
+ const node = nodes[u[i]!]
173
223
  if (node === undefined) return undefined
174
- stableKeys.push({ hash, node })
224
+ uncachedKeys.set(node.id, k[i]!)
175
225
  }
176
- return { stableKeys, uncachedKeys: new Map(parsed.u) }
226
+ return { stableKeys, uncachedKeys }
177
227
  }
178
228
 
179
229
  /**
@@ -259,14 +309,23 @@ export function writeRunKeys(
259
309
  at: { id: string; token: string },
260
310
  keys: RunKeys,
261
311
  ): void {
262
- args.hashCache.folds?.set(
263
- at.id,
264
- at.token,
265
- JSON.stringify({
266
- s: keys.stableKeys.map((k) => [k.node.id, k.hash]),
267
- u: [...keys.uncachedKeys],
268
- }),
269
- )
312
+ if (args.hashCache.folds === undefined) return
313
+ const place = new Map<string, number>()
314
+ for (const id of args.nodes.keys()) place.set(id, place.size)
315
+ const row: KeysRow = { s: [], h: [], u: [], k: [] }
316
+ for (const key of keys.stableKeys) {
317
+ const n = place.get(key.node.id)
318
+ if (n === undefined) return
319
+ row.s.push(n)
320
+ row.h.push(key.hash)
321
+ }
322
+ for (const [id, key] of keys.uncachedKeys) {
323
+ const n = place.get(id)
324
+ if (n === undefined) return
325
+ row.u.push(n)
326
+ row.k.push(key)
327
+ }
328
+ args.hashCache.folds.set(at.id, at.token, JSON.stringify(row))
270
329
  }
271
330
 
272
331
  let identity: string | undefined
@@ -43,6 +43,8 @@ export interface LiveTask {
43
43
  durationMs: number | null
44
44
  }
45
45
 
46
+ type Ended = { status: OutcomeView['status']; startedAt: number | null; durationMs: number }
47
+
46
48
  /** What `vx last --live --format json` prints, the snapshot line. */
47
49
  export interface LiveSnapshot {
48
50
  live: { runId: string; command: string; pid: number; startedAt: number; elapsedMs: number }
@@ -113,11 +115,8 @@ export function serveLive(opts: {
113
115
  nodes: () => Iterable<TaskNode>
114
116
  warnings: () => readonly string[]
115
117
  }): { unlisten(): void; dispose(): void } {
116
- const started = new Map<string, number>()
117
- const ended = new Map<
118
- string,
119
- OutcomeView['status'] | { status: OutcomeView['status']; durationMs: number }
120
- >()
118
+ // Each task's start, then its end: one map, not one for each.
119
+ const seen = new Map<string, number | Ended>()
121
120
  const peers = new Map<Socket<Peer>, Peer>()
122
121
  let followers = 0
123
122
 
@@ -155,12 +154,11 @@ export function serveLive(opts: {
155
154
  const tasks: LiveTask[] = []
156
155
  for (const node of opts.nodes()) {
157
156
  if (isGroupTask(node)) continue
158
- const at = started.get(node.id) ?? null
159
- const end = ended.get(node.id)
157
+ const at = seen.get(node.id)
160
158
  tasks.push(
161
- end !== undefined && typeof end === 'object'
162
- ? { taskId: node.id, status: end.status, startedAt: at, durationMs: end.durationMs }
163
- : at !== null
159
+ typeof at === 'object'
160
+ ? { taskId: node.id, ...at }
161
+ : at !== undefined
164
162
  ? { taskId: node.id, status: 'running', startedAt: at, durationMs: null }
165
163
  : { taskId: node.id, status: 'waiting', startedAt: null, durationMs: null },
166
164
  )
@@ -184,10 +182,15 @@ export function serveLive(opts: {
184
182
  }
185
183
 
186
184
  const unsubscribe = opts.bus.subscribe((event) => {
187
- if (event.kind === 'task:start') started.set(event.node.id, Date.now())
188
- else if (event.kind === 'task:complete') {
189
- ended.set(event.node.id, {
185
+ if (event.kind === 'task:start') {
186
+ const at = seen.get(event.node.id)
187
+ if (typeof at === 'object') at.startedAt = Date.now()
188
+ else seen.set(event.node.id, Date.now())
189
+ } else if (event.kind === 'task:complete') {
190
+ const at = seen.get(event.node.id)
191
+ seen.set(event.node.id, {
190
192
  status: event.outcome.status,
193
+ startedAt: typeof at === 'object' ? at.startedAt : (at ?? null),
191
194
  durationMs: event.outcome.durationMs,
192
195
  })
193
196
  }
@@ -5,7 +5,8 @@
5
5
  // run's restore is staging takes its files out from under it (item 215).
6
6
  // The cache itself is safe (SQLite waits, and an artifact lands by rename
7
7
  // inside its rows' transaction, A-3), across workspaces sharing one
8
- // `--cache-dir` too; the tree is not, and a run that finished "green" can
8
+ // `--cache-dir` too (a clean of it waits for their runs, `holdCacheDir`);
9
+ // the tree is not, and a run that finished "green" can
9
10
  // have had its restored outputs deleted by the other run's clean a moment
10
11
  // later. So a run takes this lock before it schedules and releases it with
11
12
  // its cache handle — before a persistent task's wait, so a dev server never
@@ -62,7 +63,7 @@
62
63
  // them and left by the last to release.
63
64
 
64
65
  import { chmodSync, lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
65
- import { rmdirSync, unlinkSync } from 'node:fs'
66
+ import { rmdirSync, rmSync, unlinkSync } from 'node:fs'
66
67
  import { mkdir, readdir, readFile, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises'
67
68
  import os from 'node:os'
68
69
  import path from 'node:path'
@@ -79,6 +80,12 @@ const takers = new Map<string, string>()
79
80
  * must wait for it, not reclaim it as a dead run of this pid.
80
81
  */
81
82
  const placed = new Set<string>()
83
+ /**
84
+ * Staging directories made and not yet renamed or removed: an exit between
85
+ * the mkdir and the rename left one, randomly named, in the lock root for
86
+ * good, no later taking knowing its name.
87
+ */
88
+ const staged = new Set<string>()
82
89
 
83
90
  /**
84
91
  * A signal exit is `process.exit` (signals.ts): no finally runs and nothing
@@ -92,6 +99,13 @@ function releaseOnExit(): void {
92
99
  if (exitHooked) return
93
100
  exitHooked = true
94
101
  process.on('exit', () => {
102
+ for (const dir of staged) {
103
+ try {
104
+ rmSync(dir, { recursive: true, force: true })
105
+ } catch {
106
+ // best effort on the way out
107
+ }
108
+ }
95
109
  for (const [lockDir, entry] of takers) {
96
110
  // The entry may be gone already: a release under way unlinked it and
97
111
  // was still to remove the directory (item 867).
@@ -292,7 +306,15 @@ async function place(lockDir: string): Promise<string | null> {
292
306
  // start time, and a restarted container's pid 1 would meet the staging
293
307
  // directory a killed pid 1 left, and refuse every run after it.
294
308
  const staging = `${lockDir}.${entry}.${Math.random().toString(36).slice(2)}`
295
- await mkdir(staging)
309
+ // Listed before the mkdir: the exit hook removes what it lists.
310
+ staged.add(staging)
311
+ releaseOnExit()
312
+ try {
313
+ await mkdir(staging)
314
+ } catch (err) {
315
+ staged.delete(staging)
316
+ throw err
317
+ }
296
318
  placed.add(entry)
297
319
  try {
298
320
  await writeFile(path.join(staging, entry), '')
@@ -304,6 +326,8 @@ async function place(lockDir: string): Promise<string | null> {
304
326
  const code = (err as NodeJS.ErrnoException).code
305
327
  if (code === 'ENOTEMPTY' || code === 'EEXIST') return null
306
328
  throw err
329
+ } finally {
330
+ staged.delete(staging)
307
331
  }
308
332
  }
309
333
 
@@ -384,7 +408,6 @@ export async function acquireRunLock(
384
408
  if (entry !== null) {
385
409
  heldHere.set(lockDir, 1)
386
410
  takers.set(lockDir, entry)
387
- releaseOnExit()
388
411
  return held
389
412
  }
390
413
  h = await holder(lockDir)
@@ -12,14 +12,10 @@ import { VERSION } from '../version.js'
12
12
  import type { CiContext, GitContext, HostContext } from './run-context.js'
13
13
  import { taskTelemetryOf, type TaskTelemetry } from './telemetry.js'
14
14
  import { isCacheHit } from '../graph/index.js'
15
+ import { neverStarted } from '../report/index.js'
15
16
 
16
17
  export interface RunRecordsInput {
17
18
  outcomes: readonly TaskOutcome[]
18
- /**
19
- * Tasks that ran behind a failed dependency (`--continue=always`) or one
20
- * `--exclude-dependencies` dropped, each with the task at the root of it.
21
- */
22
- tainted?: ReadonlyMap<string, string>
23
19
  runId: string
24
20
  /** Wall-clock ms at run start; every per-task ns offset is anchored to it. */
25
21
  startedAtMs: number
@@ -42,8 +38,16 @@ export interface RunRecordsInput {
42
38
  blocks?: ReadonlyMap<string, number>
43
39
  /** The run's remote failures by key, so far (`Cache.traffic`). */
44
40
  remoteTraffic?: ReadonlyMap<string, { failures: readonly string[] }>
41
+ /** Outcomes whose rows the history stream already wrote: counted, not written again. */
42
+ landed?: ReadonlySet<TaskOutcome>
45
43
  }
46
44
 
45
+ /** What a task's `runs` row reads besides its outcome. */
46
+ export type RunRecordContext = Pick<
47
+ RunRecordsInput,
48
+ 'runId' | 'startedAtMs' | 'endedAtMs' | 'forwardArgs' | 'blocks' | 'remoteTraffic'
49
+ >
50
+
47
51
  export interface RunRecords {
48
52
  runs: RunRecord[]
49
53
  invocation: InvocationRecord
@@ -72,6 +76,7 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
72
76
  const { outcomes, runId, startedAtMs, endedAtMs } = input
73
77
  const runs: RunRecord[] = []
74
78
  const telemetryTasks: TaskTelemetry[] = []
79
+ let taskCount = 0
75
80
  let failedCount = 0
76
81
  let hitLocalCount = 0
77
82
  let hitRemoteCount = 0
@@ -80,75 +85,16 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
80
85
  let restoredRemoteCount = 0
81
86
  for (const o of outcomes) {
82
87
  if (isGroupTask(o.node)) continue
83
- // aborted (killed by a shutdown signal) isn't a real run.
84
- if (o.status === 'aborted') continue
85
- if (input.withTelemetry) telemetryTasks.push(taskTelemetryOf(o))
86
- const blockedBy = o.blockedBy ?? input.tainted?.get(o.node.id)
87
- const remoteError = remoteErrorOf(input.remoteTraffic, o.hash)
88
- // Built field by field: a spread per optional field was ~2 µs of each
89
- // of a 1,200-hit warm run's rows.
90
- const hit = isCacheHit(o.status)
91
- const r: RunRecord = {
92
- project: o.node.projectName,
93
- task: o.node.taskName,
94
- status: o.status,
95
- exitCode: o.exitCode,
96
- durationMs: o.durationMs,
97
- // Anchor to the REAL per-task wall-clock window: run-start wall time +
98
- // the task's ns offset (captured for hits and executed tasks alike).
99
- // The `end - duration` fallback applies to outcomes without an offset —
100
- // today only `skipped`, which the scheduler finishes synchronously with
101
- // no span, so it collapses to a zero-width mark at the run's end. Using
102
- // run-end-minus-duration for EVERYTHING was the old bug that piled every
103
- // task at the right edge of the timeline.
104
- startedAt:
105
- o.wallclockStartNs !== undefined
106
- ? startedAtMs + Math.round(Number(o.wallclockStartNs) / 1e6)
107
- : endedAtMs - o.durationMs,
108
- endedAt:
109
- o.wallclockEndNs !== undefined
110
- ? startedAtMs + Math.round(Number(o.wallclockEndNs) / 1e6)
111
- : endedAtMs,
112
- runId,
113
- cacheHit: hit,
114
- cached: o.node.config.cache !== undefined,
115
- }
116
- if (o.hash !== undefined) r.hash = o.hash
117
- // A run forwards its args to the tasks it was asked for, never to a
118
- // dependency: the row says which ran with them.
119
- if (input.forwardArgs !== undefined && input.forwardArgs.length > 0 && o.node.requested)
120
- r.forwardArgs = input.forwardArgs
121
- if (o.cpuMs !== undefined) r.cpuMs = o.cpuMs
122
- if (o.peakRssBytes !== undefined) r.peakRssBytes = o.peakRssBytes
123
- if (o.peakCores !== undefined) r.peakCores = o.peakCores
124
- if (o.avgRssBytes !== undefined) r.avgRssBytes = o.avgRssBytes
125
- if (o.where !== undefined) r.placement = o.where
126
- if (o.whereCores !== undefined) r.placementCores = o.whereCores
127
- if (o.whereMemoryBytes !== undefined) r.placementMemoryBytes = o.whereMemoryBytes
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
88
+ // aborted (killed by a shutdown signal) isn't a real run. One that
89
+ // started is still in the summary's task list, as the terminal lists it;
90
+ // one the stop reached first is only `notRunCount`.
91
+ if (o.status === 'aborted') {
92
+ if (input.withTelemetry && !neverStarted(o)) telemetryTasks.push(taskTelemetryOf(o))
93
+ continue
136
94
  }
137
- if (o.wallclockStartNs !== undefined) r.wallclockStartNs = o.wallclockStartNs
138
- if (o.wallclockEndNs !== undefined) r.wallclockEndNs = o.wallclockEndNs
139
- if (hit) r.restored = o.restored === true
140
- if (o.attempts !== undefined) r.attempts = o.attempts
141
- // A task that ran behind a failure names it too, so the flakiness
142
- // projection counts its outcome on no key (failure-mode.ts).
143
- if (blockedBy !== undefined) r.blockedBy = blockedBy
144
- if (o.timedOut === true) r.timedOut = true
145
- if (o.sandboxViolations !== undefined) r.sandboxViolations = o.sandboxViolations
146
- if (o.notReady !== undefined) r.notReady = o.notReady
147
- if (remoteError !== undefined) r.remoteError = remoteError
148
- if (o.keylessBy !== undefined) r.keylessBy = o.keylessBy
149
- const def = taskDefinition(o.node)
150
- if (def !== undefined) r.def = def
151
- runs.push(r)
95
+ if (input.withTelemetry) telemetryTasks.push(taskTelemetryOf(o))
96
+ taskCount++
97
+ if (input.landed?.has(o) !== true) runs.push(runRecordOf(o as RecordedOutcome, input))
152
98
  if (o.status === 'failed') failedCount++
153
99
  if (o.status === 'cache-hit') hitLocalCount++
154
100
  if (o.status === 'cache-hit-remote') hitRemoteCount++
@@ -168,7 +114,7 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
168
114
  startedAt: startedAtMs,
169
115
  endedAt: endedAtMs,
170
116
  totalDurationMs: Math.round(input.totalMs),
171
- taskCount: runs.length,
117
+ taskCount,
172
118
  failedCount,
173
119
  hitCount: hitLocalCount + hitRemoteCount,
174
120
  hitLocalCount,
@@ -190,6 +136,81 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
190
136
  return { runs, invocation, telemetryTasks }
191
137
  }
192
138
 
139
+ /** An outcome a `runs` row records: every status but `aborted`. */
140
+ export type RecordedOutcome = TaskOutcome & { status: RunRecord['status'] }
141
+
142
+ /** One outcome's `runs` row. */
143
+ export function runRecordOf(o: RecordedOutcome, input: RunRecordContext): RunRecord {
144
+ const { runId, startedAtMs, endedAtMs } = input
145
+ const blockedBy = o.blockedBy ?? o.taintedBy
146
+ const remoteError = remoteErrorOf(input.remoteTraffic, o.hash)
147
+ // Built field by field: a spread per optional field was ~2 µs of each
148
+ // of a 1,200-hit warm run's rows.
149
+ const hit = isCacheHit(o.status)
150
+ const r: RunRecord = {
151
+ project: o.node.projectName,
152
+ task: o.node.taskName,
153
+ status: o.status,
154
+ exitCode: o.exitCode,
155
+ durationMs: o.durationMs,
156
+ // Anchor to the REAL per-task wall-clock window: run-start wall time +
157
+ // the task's ns offset (captured for hits and executed tasks alike).
158
+ // The `end - duration` fallback applies to outcomes without an offset —
159
+ // today only `skipped`, which the scheduler finishes synchronously with
160
+ // no span, so it collapses to a zero-width mark at the run's end. Using
161
+ // run-end-minus-duration for EVERYTHING was the old bug that piled every
162
+ // task at the right edge of the timeline.
163
+ startedAt:
164
+ o.wallclockStartNs !== undefined
165
+ ? startedAtMs + Math.round(Number(o.wallclockStartNs) / 1e6)
166
+ : endedAtMs - o.durationMs,
167
+ endedAt:
168
+ o.wallclockEndNs !== undefined
169
+ ? startedAtMs + Math.round(Number(o.wallclockEndNs) / 1e6)
170
+ : endedAtMs,
171
+ runId,
172
+ cached: o.node.config.cache === true,
173
+ }
174
+ // A hit whose restore failed is neither: its `cache_hit` stays NULL.
175
+ if (o.restoreFailed !== true) r.cacheHit = hit
176
+ if (o.hash !== undefined) r.hash = o.hash
177
+ // A run forwards its args to the tasks it was asked for, never to a
178
+ // dependency: the row says which ran with them.
179
+ if (input.forwardArgs !== undefined && input.forwardArgs.length > 0 && o.node.requested)
180
+ r.forwardArgs = input.forwardArgs
181
+ if (o.cpuMs !== undefined) r.cpuMs = o.cpuMs
182
+ if (o.peakRssBytes !== undefined) r.peakRssBytes = o.peakRssBytes
183
+ if (o.peakCores !== undefined) r.peakCores = o.peakCores
184
+ if (o.avgRssBytes !== undefined) r.avgRssBytes = o.avgRssBytes
185
+ if (o.where !== undefined) r.placement = o.where
186
+ if (o.whereCores !== undefined) r.placementCores = o.whereCores
187
+ if (o.whereMemoryBytes !== undefined) r.placementMemoryBytes = o.whereMemoryBytes
188
+ if (o.cpuShare !== undefined) r.cpuShare = o.cpuShare
189
+ if (o.queuedMs !== undefined) r.queuedMs = o.queuedMs
190
+ if (o.outputBytes !== undefined) r.outputBytes = o.outputBytes
191
+ const blocks = input.blocks?.get(o.node.id)
192
+ if (blocks !== undefined) r.blocks = blocks
193
+ if (o.flaky !== undefined) {
194
+ r.flakyPasses = o.flaky.passes
195
+ r.flakyFailures = o.flaky.failures
196
+ }
197
+ if (o.wallclockStartNs !== undefined) r.wallclockStartNs = o.wallclockStartNs
198
+ if (o.wallclockEndNs !== undefined) r.wallclockEndNs = o.wallclockEndNs
199
+ if (hit) r.restored = o.restored === true
200
+ if (o.attempts !== undefined) r.attempts = o.attempts
201
+ // A task that ran behind a failure names it too, so the flakiness
202
+ // projection counts its outcome on no key (failure-mode.ts).
203
+ if (blockedBy !== undefined) r.blockedBy = blockedBy
204
+ if (o.timedOut === true) r.timedOut = true
205
+ if (o.sandboxViolations !== undefined) r.sandboxViolations = o.sandboxViolations
206
+ if (o.notReady !== undefined) r.notReady = o.notReady
207
+ if (remoteError !== undefined) r.remoteError = remoteError
208
+ if (o.keylessBy !== undefined) r.keylessBy = o.keylessBy
209
+ const def = taskDefinition(o.node)
210
+ if (def !== undefined) r.def = def
211
+ return r
212
+ }
213
+
193
214
  /**
194
215
  * The remote failures uploads met after the run's rows went in (the uploads
195
216
  * drain later): each row whose text is now short of its key's is rewritten.
@@ -26,6 +26,7 @@ export type KeepRunEntry = (name: string, bytes: Uint8Array) => Promise<boolean>
26
26
  export interface RunKeeperArgs {
27
27
  readonly runId: string
28
28
  readonly workspaceRoot: string
29
+ /** Where each project's outputs stop (prepare.ts `outputWallsOf`). */
29
30
  readonly nestedDirsByProject: ReadonlyMap<string, string[]>
30
31
  readonly keep: KeepRunEntry
31
32
  /** Whether a failed task's log and outputs are kept (`cache.upload.failures`). */
@@ -44,12 +45,12 @@ export function keepsFailures(where: unknown, ci: boolean): boolean {
44
45
  const KEEP_CONCURRENCY = 8
45
46
 
46
47
  /** The files `node`'s declared outputs match on disk now, workspace-relative and sorted. */
47
- async function outputsLeft(
48
+ export async function outputsLeft(
48
49
  node: TaskNode,
49
50
  workspaceRoot: string,
50
51
  nestedProjectDirs: string[],
51
52
  ): Promise<string[]> {
52
- const declared = node.config.cache?.outputs
53
+ const declared = node.config.outputs
53
54
  if (declared === undefined) return []
54
55
  const [own, ws] = await Promise.all([
55
56
  resolveOutputs({ projectDir: node.projectDir, outputs: declared.files, nestedProjectDirs }),
@@ -18,6 +18,7 @@ import { hasEnded } from './persistent.js'
18
18
  import {
19
19
  forwardedSignal,
20
20
  forwardSignals,
21
+ type HeldSignals,
21
22
  SIGNAL_SHUTDOWN_GRACE_MS,
22
23
  type StopSignal,
23
24
  terminateChildren,
@@ -26,10 +27,11 @@ import {
26
27
  type Child = ReturnType<typeof Bun.spawn>
27
28
 
28
29
  /**
29
- * The abort reason that stops a run's own work and spares the servers it
30
- * was handed (`RunOptions.keep`), which it hands back. `vx watch` stops a
31
- * cycle waiting on a server that never becomes ready (WD-15); any other
32
- * stop took the unchanged servers down with it, and they restarted.
30
+ * The abort reason that stops a run's own work and spares its ready
31
+ * servers, handed in (`RunOptions.keep`) or made ready by the run, which
32
+ * it hands back. `vx watch` stops a cycle waiting on a server that never
33
+ * becomes ready (WD-15); any other stop took the unchanged servers down
34
+ * with it, and they restarted.
33
35
  */
34
36
  export const CYCLE_INTERRUPT = 'vx:cycle-interrupt'
35
37
 
@@ -77,25 +79,26 @@ export function runStop(args: {
77
79
  /** `RunOptions.handleSignals`. */
78
80
  handleSignals: boolean
79
81
  handsTerminal: boolean
82
+ /** What the run heard since it went for its lock (`holdSignals`). */
83
+ held: HeldSignals | undefined
80
84
  /** How many plugins have a teardown: each gets its own bound before a signal exits. */
81
85
  teardowns: number
82
86
  /** Called first when the run is stopped. */
83
87
  onStop: () => void
84
- /** A server the run was handed: a `CYCLE_INTERRUPT` leaves it running. */
85
- handed: (child: Child) => boolean
86
88
  }): RunStop {
87
89
  const { log, external } = args
88
90
  const liveChildren = new Set<Child>()
89
91
  // A server that ends on its own while the graph still runs is said at
90
92
  // once: its dependants were failing against it, and the end of the run
91
93
  // was the first word of it (C-69). Not once the graph is done (the end
92
- // of the run and the keep-alive wait say it) nor under a stop.
94
+ // of the run and the keep-alive wait say it) nor under a stop, nor once
95
+ // the run took it out to stop it (no task left needed it).
93
96
  let graphDone = false
94
97
  const persistentRegistry = new (class extends Map<string, Child> {
95
98
  override set(id: string, child: Child): this {
96
99
  super.set(id, child)
97
100
  void child.exited.then((code) => {
98
- if (code !== 0 && !graphDone && !controller.signal.aborted)
101
+ if (code !== 0 && !graphDone && !controller.signal.aborted && this.get(id) === child)
99
102
  warnOf(log)(`vx: ${id} exited with code ${code} while the run went on`)
100
103
  })
101
104
  return this
@@ -126,6 +129,7 @@ export function runStop(args: {
126
129
  },
127
130
  done: runLeft,
128
131
  handsTerminal: args.handsTerminal,
132
+ held: args.held,
129
133
  // The grace, then one flush and each teardown at their own bound, and
130
134
  // slack for the summary and the cache close.
131
135
  boundMs:
@@ -136,7 +140,11 @@ export function runStop(args: {
136
140
  const onAbort = (): void => {
137
141
  args.onStop()
138
142
  endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
139
- const spare = controller.signal.reason === CYCLE_INTERRUPT ? args.handed : () => false
143
+ // Every ready server is in the registry, the handed ones included: the
144
+ // initial watch run is handed none, and its ready servers restarted.
145
+ const ready = new Set(persistentRegistry.values())
146
+ const spare = (c: Child): boolean =>
147
+ controller.signal.reason === CYCLE_INTERRUPT && ready.has(c)
140
148
  aborting = terminateChildren(
141
149
  () => [...liveChildren, ...persistentRegistry.values()].filter((c) => !spare(c)),
142
150
  forwardedSignal(controller.signal.reason),