@vzn/vx 0.0.591 → 0.0.595

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.cjs +9 -2
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +18 -7
  5. package/src/cache/cache.ts +137 -101
  6. package/src/cache/config-evals.ts +19 -4
  7. package/src/cache/git-inputs.ts +33 -17
  8. package/src/cache/inputs.ts +157 -54
  9. package/src/cache/layered-cache.ts +25 -2
  10. package/src/cache/tar-stream.ts +4 -2
  11. package/src/cli/cache.ts +4 -16
  12. package/src/cli/completions.ts +42 -6
  13. package/src/cli/foreign-flags.ts +56 -6
  14. package/src/cli/help.ts +27 -8
  15. package/src/cli/info.ts +4 -6
  16. package/src/cli/init.ts +4 -4
  17. package/src/cli/last.ts +49 -20
  18. package/src/cli/lock.ts +3 -3
  19. package/src/cli/plan-format.ts +9 -0
  20. package/src/cli/plugin-templates.ts +2 -2
  21. package/src/cli/run.ts +80 -50
  22. package/src/cli/select.ts +44 -5
  23. package/src/cli/show.ts +47 -9
  24. package/src/cli/upgrade.ts +2 -3
  25. package/src/cli/watch-filter.ts +21 -7
  26. package/src/cli/watch-fs.ts +32 -8
  27. package/src/cli/watch-judge.ts +1 -1
  28. package/src/cli/watch.ts +40 -15
  29. package/src/cli/why.ts +4 -13
  30. package/src/cli/workspace-config.ts +1 -1
  31. package/src/config.ts +5 -5
  32. package/src/exec/env.ts +8 -5
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +5 -1
  35. package/src/exec/runner.ts +126 -47
  36. package/src/exec/sandbox-deny-scan.ts +2 -2
  37. package/src/exec/sandbox-paths.ts +11 -5
  38. package/src/exec/sandbox-runtime.ts +89 -23
  39. package/src/exec/sandbox-violations.ts +23 -9
  40. package/src/graph/index.ts +1 -0
  41. package/src/graph/scheduler.ts +14 -4
  42. package/src/graph/task-graph.ts +3 -2
  43. package/src/index.ts +9 -0
  44. package/src/orchestrator/affected-tasks.ts +34 -10
  45. package/src/orchestrator/doctor.ts +2 -1
  46. package/src/orchestrator/execute-task.ts +43 -10
  47. package/src/orchestrator/hit-restore.ts +9 -5
  48. package/src/orchestrator/index.ts +1 -0
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +45 -49
  51. package/src/orchestrator/logger.ts +4 -1
  52. package/src/orchestrator/miss-save.ts +10 -3
  53. package/src/orchestrator/placement.ts +17 -3
  54. package/src/orchestrator/plan.ts +25 -1
  55. package/src/orchestrator/plugin-host.ts +41 -3
  56. package/src/orchestrator/plugin.ts +11 -14
  57. package/src/orchestrator/projects.ts +18 -9
  58. package/src/orchestrator/run-context.ts +2 -2
  59. package/src/orchestrator/run-lock.ts +14 -4
  60. package/src/orchestrator/run.ts +94 -16
  61. package/src/orchestrator/sandbox-request.ts +6 -10
  62. package/src/orchestrator/signals.ts +5 -2
  63. package/src/orchestrator/summary.ts +14 -1
  64. package/src/util/index.ts +7 -1
  65. package/src/util/secret-mask.ts +36 -0
  66. package/src/util/settle.ts +15 -0
  67. package/src/workspace/affected.ts +47 -19
  68. package/src/workspace/config-cache.ts +25 -4
  69. package/src/workspace/config-eval.ts +4 -0
  70. package/src/workspace/config-imports.ts +2 -2
  71. package/src/workspace/config-schema.ts +47 -10
  72. package/src/workspace/filter.ts +5 -4
  73. package/src/workspace/migrate-scripts.ts +23 -6
  74. package/src/workspace/package-graph.ts +15 -3
  75. package/src/workspace/project-loader.ts +89 -2
  76. package/src/workspace/workspace.ts +121 -14
@@ -44,8 +44,8 @@ const SYSCALLS = 'openat|access|statx|newfstatat'
44
44
  const QUOTED = '"((?:[^"\\\\]|\\\\.)+)"'
45
45
  // The directory descriptor a call names, `-y`'s path included: taken up to
46
46
  // the `, "` that opens the path argument, since a directory's name may hold
47
- // a quote (`4</ws/q"d>`).
48
- const DIRFD = '(?:AT_FDCWD|\\d+)(?:<.*?>)?, '
47
+ // a quote (`4</ws/q"d>`). Captured: a relative path is in that directory.
48
+ const DIRFD = '(?:AT_FDCWD|\\d+)(?:<(.*?)>)?, '
49
49
  const STRACE_DONE_RE = new RegExp(
50
50
  `^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}[^)]*\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
51
51
  )
@@ -163,7 +163,7 @@ type Op = { chdir: string } | { lost: true } | { call: number }
163
163
  export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCall[] {
164
164
  const pending = new Map<
165
165
  string,
166
- { syscall: string; rawPath: string; read?: true; dirfd?: string }
166
+ { syscall: string; rawPath: string; read?: true; dirfd?: string; dir?: string }
167
167
  >()
168
168
  const out: DeniedCall[] = []
169
169
  const ops = new Map<string, Op[]>()
@@ -210,19 +210,22 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
210
210
  continue
211
211
  }
212
212
  const done = STRACE_DONE_RE.exec(line)
213
- if (done?.[2] !== undefined && done[3] !== undefined && done[4] !== undefined) {
214
- denied(done[1]!, { syscall: done[2], rawPath: cStringPath(done[3]), errno: done[4] })
213
+ if (done?.[2] !== undefined && done[4] !== undefined && done[5] !== undefined) {
214
+ const rawPath = cStringPath(done[4])
215
+ denied(done[1]!, { syscall: done[2], rawPath, errno: done[5], ...within(done[3], rawPath) })
215
216
  continue
216
217
  }
217
218
  const unfinished = STRACE_UNFINISHED_RE.exec(line)
218
219
  if (
219
220
  unfinished?.[1] !== undefined &&
220
221
  unfinished[2] !== undefined &&
221
- unfinished[3] !== undefined
222
+ unfinished[4] !== undefined
222
223
  ) {
224
+ const rawPath = cStringPath(unfinished[4])
223
225
  pending.set(unfinished[1], {
224
226
  syscall: unfinished[2],
225
- rawPath: cStringPath(unfinished[3]),
227
+ rawPath,
228
+ ...within(unfinished[3], rawPath),
226
229
  ...(reads && OPEN_READ_UNFINISHED_RE.test(line) ? { read: true as const } : {}),
227
230
  ...(reads ? { dirfd: OPEN_READ_UNFINISHED_RE.exec(line)?.[2] ?? 'AT_FDCWD' } : {}),
228
231
  })
@@ -237,7 +240,12 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
237
240
  // resume just retires the pending entry.
238
241
  const fd = held?.read === true ? RESUMED_FD_RE.exec(line) : null
239
242
  if (held !== undefined && resumed?.[3] !== undefined) {
240
- denied(resumedOk[1], { syscall: held.syscall, rawPath: held.rawPath, errno: resumed[3] })
243
+ denied(resumedOk[1], {
244
+ syscall: held.syscall,
245
+ rawPath: held.rawPath,
246
+ errno: resumed[3],
247
+ ...(held.dir !== undefined ? { dir: held.dir } : {}),
248
+ })
241
249
  } else if (held !== undefined && fd !== null) {
242
250
  const at = readPath(held.dirfd ?? 'AT_FDCWD', held.rawPath, fd[1])
243
251
  if (at !== undefined) {
@@ -250,6 +258,12 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
250
258
  // without the cwd tracking.
251
259
  follow(line)
252
260
  }
261
+ // `-y` names the directory a relative path was opened in: a denial
262
+ // through a descriptor (`openat(4</ws/p/sub>, "g")`) was placed in the
263
+ // cwd, naming a file the task never asked for.
264
+ function within(dir: string | undefined, rawPath: string): { dir?: string } {
265
+ return dir !== undefined && !rawPath.startsWith('/') ? { dir: cStringPath(dir) } : {}
266
+ }
253
267
  function follow(line: string): void {
254
268
  let m: RegExpExecArray | null
255
269
  if ((m = CHDIR_DENIED_RE.exec(line)) !== null) {
@@ -303,7 +317,7 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
303
317
  }
304
318
  for (const [pid, list] of ops) {
305
319
  list.forEach((op, i) => {
306
- if (!('call' in op)) return
320
+ if (!('call' in op) || out[op.call]!.dir !== undefined) return
307
321
  const dir = dirsOf(pid)[i]
308
322
  if (dir !== undefined && dir !== cwd) out[op.call]!.dir = dir
309
323
  })
@@ -11,6 +11,7 @@ export {
11
11
  export {
12
12
  type ContinueMode,
13
13
  deadServerBehind,
14
+ idleServers,
14
15
  type InputChanges,
15
16
  RestoreDemoted,
16
17
  runGraph,
@@ -208,7 +208,7 @@ export interface ScheduleOptions {
208
208
  * - 'deps-ok': a failed/skipped/aborted upstream skips its
209
209
  * dependents; independent siblings keep running.
210
210
  * - 'never': the first failure stops dispatch — in-flight tasks
211
- * finish naturally, everything not yet started completes as
211
+ * finish their attempt but start no retry, everything not yet started completes as
212
212
  * skipped (restore-tier included: a fail-fast run stops
213
213
  * restoring too).
214
214
  * - 'always': dependents run even when an upstream failed. Their
@@ -227,6 +227,11 @@ export interface ScheduleOptions {
227
227
  * 'never' it stops dispatch.
228
228
  */
229
229
  serverDied?: (id: string) => boolean
230
+ /**
231
+ * Called once, when 'never' stops dispatch: a task in flight then
232
+ * finishes its attempt but starts no retry.
233
+ */
234
+ onFailFast?: () => void
230
235
  execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
231
236
  onStart?: (node: TaskNode) => void
232
237
  onFinish?: (outcome: TaskOutcome) => void
@@ -424,6 +429,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
424
429
  // dequeued task is skipped instead of dispatched (in-flight tasks
425
430
  // finish naturally and their dependents drain through the same path).
426
431
  let failFastTripped = false
432
+ const tripFailFast = (): void => {
433
+ if (failFastTripped) return
434
+ failFastTripped = true
435
+ options.onFailFast?.()
436
+ }
427
437
  const outcomes = new Map<string, TaskOutcome>()
428
438
 
429
439
  // Reverse adjacency + pending dep counts. Built once. A task becomes
@@ -588,7 +598,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
588
598
 
589
599
  return new Promise<Map<string, TaskOutcome>>((resolve) => {
590
600
  const finishOne = (id: string, outcome: TaskOutcome): void => {
591
- if (continueMode === 'never' && outcome.status === 'failed') failFastTripped = true
601
+ if (continueMode === 'never' && outcome.status === 'failed') tripFailFast()
592
602
  outcomes.set(id, outcome)
593
603
  // A dormant server is asked again: the dispatch settles it once its
594
604
  // last dependant is in.
@@ -686,7 +696,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
686
696
  const willSkip = (id: string): boolean => {
687
697
  if (failFastTripped || aborted()) return true
688
698
  if (continueMode === 'never' && servers.some(serverDied)) {
689
- failFastTripped = true
699
+ tripFailFast()
690
700
  return true
691
701
  }
692
702
  if (inRestoreTier(id)) return false
@@ -959,7 +969,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
959
969
  }
960
970
 
961
971
  /** Persistent tasks nobody asked for whose every dependant is a restore-tier hit. */
962
- function idleServers(
972
+ export function idleServers(
963
973
  nodes: ReadonlyMap<string, TaskNode>,
964
974
  dependents: ReadonlyMap<string, string[]>,
965
975
  restoreTier: ReadonlySet<string> | undefined,
@@ -3,6 +3,7 @@ import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
3
3
  import {
4
4
  asTrees,
5
5
  isLiteralPattern,
6
+ relPosix,
6
7
  splitNegations,
7
8
  staticPrefix,
8
9
  taskGlob,
@@ -872,7 +873,7 @@ function detectOutputCollisions(
872
873
  const entries: Entry[] = wsDeclarers.map((node) => ({ node, globs: wsFilesOf(node) ?? [] }))
873
874
  for (const bucket of byProject.values()) {
874
875
  for (const node of bucket) {
875
- const rel = path.relative(workspaceRoot, node.projectDir).split(path.sep).join('/')
876
+ const rel = relPosix(workspaceRoot, node.projectDir)
876
877
  const globs = (filesOf(node) ?? []).map((g) => (rel === '' ? g : `${rel}/${g}`))
877
878
  entries.push({ node, globs, rel })
878
879
  }
@@ -1243,7 +1244,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1243
1244
  if (!rootReaders) return
1244
1245
  if (workspaceRoot !== undefined) {
1245
1246
  for (const node of filesWriters) {
1246
- const rel = path.relative(workspaceRoot, node.projectDir).split(path.sep).join('/')
1247
+ const rel = relPosix(workspaceRoot, node.projectDir)
1247
1248
  const globs = splitNegations(node.config.cache!.outputs.files).positive.map((g) =>
1248
1249
  rel === '' ? g : `${rel}/${g}`,
1249
1250
  )
package/src/index.ts CHANGED
@@ -168,6 +168,9 @@ export type {
168
168
  DiscoverContext,
169
169
  NamedProject,
170
170
  PluginSetupContext,
171
+ PluginHookName,
172
+ PluginHookHandlers,
173
+ RunStartInfo,
171
174
  } from './orchestrator/index.js'
172
175
  // The per-task execution contract a plugin's `executor` capability returns.
173
176
  // (`runCommand` / `runSandboxed`, the local executor's own primitives, left
@@ -176,6 +179,12 @@ export type {
176
179
  // A remote executor gives a task back to the local floor by rejecting with
177
180
  // this (B-100): core runs it here and says why once.
178
181
  export { executorFallback } from './exec/index.js'
182
+ // How the local executor puts the args after `--` on a command, for an
183
+ // executor that runs `ExecuteRequest.command` itself: `@vzn/vx-reapi` and
184
+ // the `vx init --plugin executor` example each joined their own, and a
185
+ // trailing comment or a template literal's closing newline lost the args
186
+ // there while the local floor kept them (X-112).
187
+ export { withForwardArgs } from './exec/index.js'
179
188
  export type {
180
189
  ExecuteRequest,
181
190
  ExecuteResult,
@@ -105,16 +105,40 @@ export function affectedRoots(
105
105
  return (changes.paths.get(n.projectName) ?? []).some((rel) => declaresInput(cache, rel, null))
106
106
  }
107
107
  const reached = new Map<string, boolean>()
108
- const reaches = (id: string): boolean => {
109
- const known = reached.get(id)
110
- if (known !== undefined) return known
111
- // The graph is acyclic (the builder refuses a cycle); the mark only
112
- // stops a diamond being walked twice.
113
- reached.set(id, false)
114
- const n = nodes.get(id)
115
- const hit = n !== undefined && (seeded(n) || passesChanged(n) || n.deps.some(reaches))
116
- reached.set(id, hit)
117
- return hit
108
+ // Post-order on an explicit stack: a closure is as deep as the graph, and
109
+ // a recursion per edge threw `RangeError` on a chain the builder takes
110
+ // (item 737's 50,000; ~15,000 sufficed here). A frame holds the next dep
111
+ // to ask, -1 before the task itself is.
112
+ const reaches = (root: string): boolean => {
113
+ const stack: Array<[id: string, next: number]> = [[root, -1]]
114
+ while (stack.length > 0) {
115
+ const frame = stack[stack.length - 1]!
116
+ const id = frame[0]
117
+ if (frame[1] === -1) {
118
+ if (reached.has(id)) {
119
+ stack.pop()
120
+ continue
121
+ }
122
+ const n = nodes.get(id)
123
+ if (n === undefined || seeded(n) || passesChanged(n)) {
124
+ reached.set(id, n !== undefined)
125
+ stack.pop()
126
+ continue
127
+ }
128
+ frame[1] = 0
129
+ }
130
+ const deps = nodes.get(id)!.deps
131
+ let i = frame[1]
132
+ while (i < deps.length && reached.get(deps[i]!) === false) i++
133
+ frame[1] = i
134
+ if (i === deps.length || reached.get(deps[i]!) === true) {
135
+ reached.set(id, i < deps.length)
136
+ stack.pop()
137
+ continue
138
+ }
139
+ stack.push([deps[i]!, -1])
140
+ }
141
+ return reached.get(root)!
118
142
  }
119
143
  return ids.filter(reaches)
120
144
  }
@@ -16,6 +16,7 @@ import {
16
16
  isUnsupportedBun,
17
17
  machineMemoryBytes,
18
18
  machineParallelism,
19
+ relPosix,
19
20
  } from '../util/index.js'
20
21
  import { VERSION } from '../version.js'
21
22
  import { probeSandbox, resetSandbox, untracedReason } from '../exec/index.js'
@@ -395,7 +396,7 @@ async function countLoadableTasks(
395
396
  ? bare.slice(where.length + 1).trimStart()
396
397
  : raw
397
398
  errors.push({
398
- path: path.relative(root, where).split(path.sep).join('/'),
399
+ path: relPosix(root, where),
399
400
  message,
400
401
  })
401
402
  }
@@ -38,6 +38,7 @@ import {
38
38
  type TaskInputs,
39
39
  PersistentReadyError,
40
40
  sandboxReads,
41
+ maskCaptured,
41
42
  } from '../exec/index.js'
42
43
  import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
43
44
  import {
@@ -51,7 +52,7 @@ import {
51
52
  span,
52
53
  UserError,
53
54
  } from '../util/index.js'
54
- import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
55
+ import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS, terminateChildren } from './signals.js'
55
56
  import { executorLabel, nameExecutorFailure } from './plugin-host.js'
56
57
  import {
57
58
  mayWriteFingerprint,
@@ -133,6 +134,8 @@ export interface ExecuteArgs {
133
134
  * terms (item 962).
134
135
  */
135
136
  stopSignal?: AbortSignal
137
+ /** `continueMode: 'never'` stopped dispatch: no further attempt starts. */
138
+ failFast?: AbortSignal
136
139
  /**
137
140
  * Registry the orchestrator owns. For each persistent task we
138
141
  * spawn, we stash the subprocess handle here so the orchestrator
@@ -464,6 +467,11 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
464
467
  try {
465
468
  await spawn.ready
466
469
  } catch (err) {
470
+ // A shell that exited before ready can leave its group running
471
+ // (`server & …; exit`): never registered, so no teardown reached it,
472
+ // and it held its port against the next `vx watch` cycle's server.
473
+ if (err instanceof PersistentReadyError && err.reason === 'exited')
474
+ await terminateChildren(() => [spawn.child])
467
475
  // A server the run's stop killed while it started is aborted, as any
468
476
  // task the stop kills (item 962): it read `failed (never ready:
469
477
  // exited, exit 130)` with a recap after every Ctrl-C (C-62). The stop
@@ -512,6 +520,10 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
512
520
  // One the readiness timeout is killing reports the signal's, as an
513
521
  // ordinary timeout does (X-24).
514
522
  const ready = err instanceof PersistentReadyError ? err : undefined
523
+ // The timer's SIGKILL is a grace away and the shell may die on the
524
+ // TERM first: a server that ignores it held its port past run() into
525
+ // the next `vx watch` cycle. Return once the group is gone.
526
+ if (ready?.reason === 'timeout') await terminateChildren(() => [spawn.child])
515
527
  return {
516
528
  node,
517
529
  status: 'failed',
@@ -523,6 +535,20 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
523
535
  }
524
536
  }
525
537
 
538
+ // Ready only once the stop had landed: a trap that prints the marker on
539
+ // the way down, or a race with the signal. The stop is killing it (it
540
+ // is in `liveChildren`), and it served nobody, as a one-shot that exits
541
+ // 0 on the stop is aborted (item 962).
542
+ if (isAborted(args.stopSignal)) {
543
+ return {
544
+ node,
545
+ status: 'aborted',
546
+ exitCode: signalExitCode(forwardedSignal(args.stopSignal!.reason)),
547
+ durationMs: spawn.readyMs(),
548
+ wallclockStartNs,
549
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
550
+ }
551
+ }
526
552
  args.persistentRegistry?.set(node.id, spawn.child)
527
553
  forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
528
554
  if (mayWriteFingerprint(node, args.workspaceRoot)) args.fingerprintWatch?.wrote()
@@ -903,7 +929,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
903
929
  // re-create would stay in a same-project consumer's input set, keeping
904
930
  // that consumer's key unchanged while the file is gone from disk.
905
931
  const endClean = span('miss: clean outputs')
906
- const cleanedRels = await cleanOutputs({ ...cleanArgs, keepGlobRoots: true })
932
+ const cleanedRels = await cleanOutputs(cleanArgs)
907
933
  endClean()
908
934
  args.gitFilesCache?.noteClean(node.id, node.projectDir, cleanedRels)
909
935
  args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
@@ -977,7 +1003,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
977
1003
  })
978
1004
  endExec()
979
1005
  if (secrets !== null)
980
- res = { ...res, stdout: secrets.mask(res.stdout), stderr: secrets.mask(res.stderr) }
1006
+ res = {
1007
+ ...res,
1008
+ stdout: maskCaptured(res.stdout, secrets),
1009
+ stderr: maskCaptured(res.stderr, secrets),
1010
+ }
981
1011
  // An executor that stopped on the timeout's abort exits non-zero; say
982
1012
  // why, so the frame, the retry line and `timedOut` read as a timeout.
983
1013
  if (timeoutFired && res.exitCode !== 0 && res.timedOut !== true) {
@@ -1040,8 +1070,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1040
1070
  // 258) — and an exit above 128 is a signal's number and nothing
1041
1071
  // about what sent it (259). One frame line names the rule. A spawn that
1042
1072
  // threw ran no shell: its 127 is vx's, and the line would send the
1043
- // reader after a command that exists (A-41).
1044
- if (!res.timedOut && res.spawnFailed !== true) {
1073
+ // reader after a command that exists (A-41). A run's stop killed it
1074
+ // itself: the SIGKILL past the grace read as the OOM killer's.
1075
+ if (!res.timedOut && res.spawnFailed !== true && args.stopSignal?.aborted !== true) {
1045
1076
  const verdict = shellVerdict({
1046
1077
  code,
1047
1078
  command: step.command,
@@ -1058,7 +1089,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1058
1089
  // no edge to the producer fails naming only the file (A-48).
1059
1090
  if (code !== 0) {
1060
1091
  for (const c of (args.gitFilesCache?.trackedCleansMissing(node.id) ?? []).slice(0, 3)) {
1061
- const rel = path.relative(args.workspaceRoot, c.path).split(path.sep).join('/')
1092
+ const rel = relPosix(args.workspaceRoot, c.path)
1062
1093
  log.taskStderr(
1063
1094
  node,
1064
1095
  `\n[vx] ${rel} is tracked by git, and ${c.by} removed it as an output before its run; it is still missing. A task that reads it needs dependsOn on ${c.by}.\n`,
@@ -1153,7 +1184,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1153
1184
  }
1154
1185
  }
1155
1186
 
1156
- if (effectiveExitCode === 0 || attempt >= maxAttempts) break
1187
+ if (effectiveExitCode === 0 || attempt >= maxAttempts || args.failFast?.aborted === true) break
1157
1188
  failedAttempts.push({
1158
1189
  endedAt: Date.now(),
1159
1190
  exitCode: effectiveExitCode,
@@ -1192,7 +1223,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1192
1223
  },
1193
1224
  }
1194
1225
  : {}),
1195
- signal: requestSignal(),
1196
1226
  ...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
1197
1227
  ...(inputs !== undefined ? { inputs } : {}),
1198
1228
  ...(cfgCacheable ? { cacheKey: hash } : {}),
@@ -1204,7 +1234,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1204
1234
  outputs: { files: outputs, workspaceFiles: wsOutputs },
1205
1235
  ...(args.terminal === true ? { terminal: true as const } : {}),
1206
1236
  }
1207
- if (!userSandbox) return base
1237
+ // The signal last: it arms `exec.timeout`, and the sandbox's arming
1238
+ // below is vx's work, not the task's. Armed first, a 60 ms timeout
1239
+ // expired before the spawn and failed `echo` as timed out, unrun.
1240
+ if (!userSandbox) return { ...base, signal: requestSignal() }
1208
1241
  await args.armSandbox?.()
1209
1242
  const sb = await sandboxRequestFor(
1210
1243
  node,
@@ -1216,7 +1249,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1216
1249
  )
1217
1250
  placeholders = sb.placeholders
1218
1251
  withheld = sb.withheld
1219
- return { ...base, sandbox: sb.sandbox }
1252
+ return { ...base, sandbox: sb.sandbox, signal: requestSignal() }
1220
1253
  }
1221
1254
 
1222
1255
  const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
@@ -8,7 +8,6 @@
8
8
  // which bytes a green run replays. Moved out of execute-task.ts
9
9
  // 2026-09-10 as pure code motion, the mirror of miss-save.ts.
10
10
 
11
- import path from 'node:path'
12
11
  import type { CacheConfig } from '../config.js'
13
12
  import {
14
13
  type CacheEntry,
@@ -22,6 +21,7 @@ import {
22
21
  import type { TaskOutcome } from '../graph/index.js'
23
22
  import {
24
23
  asTrees,
24
+ relPosix,
25
25
  secretMask,
26
26
  span,
27
27
  splitNegations,
@@ -165,10 +165,10 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
165
165
  const expectedRels = new Set(projExpected.map((e) => e.path))
166
166
  const expectedWsRels = new Set(wsExpected.map((e) => e.path))
167
167
  const actualRels = actualAbs
168
- .map((p) => path.relative(node.projectDir, p).split(path.sep).join('/'))
168
+ .map((p) => relPosix(node.projectDir, p))
169
169
  .filter((rel) => expectedRels.has(rel) || !isAddition(rel))
170
170
  const actualWsRels = actualWsAbs
171
- .map((p) => path.relative(args.workspaceRoot, p).split(path.sep).join('/'))
171
+ .map((p) => relPosix(args.workspaceRoot, p))
172
172
  .filter((rel) => expectedWsRels.has(rel) || !isAddition(rel))
173
173
  const rowsPresent = (
174
174
  actual: readonly string[],
@@ -223,11 +223,15 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
223
223
  let cleanedWsRels: string[] = []
224
224
  if (additive) {
225
225
  cleanedRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
226
- await cleanOutputPaths({ projectDir: node.projectDir, rels: cleanedRels })
226
+ await cleanOutputPaths({ projectDir: node.projectDir, rels: cleanedRels, outputs })
227
227
  cleanedWsRels = hit.outputFiles
228
228
  .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
229
229
  .map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length))
230
- await cleanOutputPaths({ projectDir: args.workspaceRoot, rels: cleanedWsRels })
230
+ await cleanOutputPaths({
231
+ projectDir: args.workspaceRoot,
232
+ rels: cleanedWsRels,
233
+ outputs: wsOutputs,
234
+ })
231
235
  } else {
232
236
  if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
233
237
  if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
@@ -43,6 +43,7 @@ export type {
43
43
  EventBus,
44
44
  RunEvent,
45
45
  RunEventSubscriber,
46
+ RunStartInfo,
46
47
  WireEvent,
47
48
  TaskView,
48
49
  OutcomeView,
@@ -194,7 +194,10 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
194
194
  * task's key, which is preliminary, so it cannot restore early either.
195
195
  * Cost: one pass over the nodes, no filesystem.
196
196
  */
197
- function restoreTierExclusions(nodes: Map<string, TaskNode>, workspaceRoot: string): Set<string> {
197
+ export function restoreTierExclusions(
198
+ nodes: Map<string, TaskNode>,
199
+ workspaceRoot: string,
200
+ ): Set<string> {
198
201
  const prefixes: string[] = []
199
202
  let everything = false
200
203
  for (const node of nodes.values()) {
@@ -9,13 +9,13 @@
9
9
  //
10
10
  // The digests are memoised on disk under the cache dir by the file's
11
11
  // xxh3, so a warm run pays one read + hash + a small JSON read, never a
12
- // parse; within one process (a run, a watch cycle) the file's size and
13
- // mtime gate even the read; and per run the read happens once (a WeakMap
12
+ // parse; within one process (a run, a watch cycle) the file's size,
13
+ // mtime, ctime and inode gate even the read; and per run the read happens once (a WeakMap
14
14
  // on the context core hands every `key` call of a run).
15
15
 
16
16
  import path from 'node:path'
17
17
  import { rename, stat } from 'node:fs/promises'
18
- import { xxh3, xxh3hex } from '../util/index.js'
18
+ import { relPosix, xxh3, xxh3hex } from '../util/index.js'
19
19
  import type { TaskNode } from '../graph/index.js'
20
20
  import type {
21
21
  FingerprintChange,
@@ -87,18 +87,33 @@ async function hashExtra(workspaceRoot: string, rel: string): Promise<string> {
87
87
  }
88
88
  }
89
89
 
90
- async function statsOf(
90
+ /**
91
+ * A file's identity for the in-process gate, as `Cache.hashFile`'s memo
92
+ * keys one: size and mtime alone kept the digests of a lockfile or patch
93
+ * replaced by another of the same size with its mtime kept (`cp -p`,
94
+ * `tar -x`), and no task sets a ctime.
95
+ */
96
+ function identityOf(st: { size: number; mtimeMs: number; ctimeMs: number; ino: number }): string {
97
+ return `${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`
98
+ }
99
+
100
+ async function statsOf(workspaceRoot: string, rels: readonly string[]): Promise<string[]> {
101
+ return Promise.all(
102
+ rels.map((rel) => stat(path.join(workspaceRoot, rel)).then(identityOf, () => 'gone')),
103
+ )
104
+ }
105
+
106
+ /** Each file's identity, taken BEFORE its hash, so a write between the two re-reads it. */
107
+ async function hashExtras(
91
108
  workspaceRoot: string,
92
109
  rels: readonly string[],
93
- ): Promise<Array<{ size: number; mtimeMs: number }>> {
94
- return Promise.all(
95
- rels.map((rel) =>
96
- stat(path.join(workspaceRoot, rel)).then(
97
- (st) => ({ size: st.size, mtimeMs: st.mtimeMs }),
98
- () => ({ size: -1, mtimeMs: -1 }),
99
- ),
100
- ),
110
+ ): Promise<{ hashes: Record<string, string>; stats: Record<string, string> }> {
111
+ const ids = await statsOf(workspaceRoot, rels)
112
+ const stats = Object.fromEntries(rels.map((rel, i) => [rel, ids[i]!] as const))
113
+ const hashes = Object.fromEntries(
114
+ await Promise.all(rels.map(async (rel) => [rel, await hashExtra(workspaceRoot, rel)] as const)),
101
115
  )
116
+ return { hashes, stats }
102
117
  }
103
118
 
104
119
  export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks {
@@ -108,15 +123,14 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
108
123
  if (scope !== 'project' && scope !== 'workspace') {
109
124
  throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
110
125
  }
111
- // Per workspace root: what the last read saw. `size` + `mtimeMs` gate a
112
- // re-read; the content hash decides whether the digests are current.
126
+ // Per workspace root: what the last read saw. Each file's identity gates
127
+ // a re-read; the content hash decides whether the digests are current.
113
128
  const seen = new Map<
114
129
  string,
115
130
  {
116
- size: number
117
- mtimeMs: number
131
+ identity: string
118
132
  extras: readonly string[]
119
- extraStats: ReadonlyArray<{ size: number; mtimeMs: number }>
133
+ extraStats: Readonly<Record<string, string>>
120
134
  digests: Digests
121
135
  }
122
136
  >()
@@ -125,23 +139,18 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
125
139
 
126
140
  const load = async (workspaceRoot: string, cacheDir: string): Promise<Digests> => {
127
141
  const full = path.join(workspaceRoot, file)
128
- let st: { size: number; mtimeMs: number }
142
+ let identity: string
129
143
  try {
130
- st = await stat(full)
144
+ identity = identityOf(await stat(full))
131
145
  } catch {
132
146
  const digests: Digests = { lock: '', importers: new Map() }
133
- seen.set(workspaceRoot, { size: -1, mtimeMs: -1, extras: [], extraStats: [], digests })
147
+ seen.set(workspaceRoot, { identity: 'gone', extras: [], extraStats: {}, digests })
134
148
  return digests
135
149
  }
136
150
  const last = seen.get(workspaceRoot)
137
- if (last !== undefined && last.size === st.size && last.mtimeMs === st.mtimeMs) {
151
+ if (last !== undefined && last.identity === identity) {
138
152
  const now = await statsOf(workspaceRoot, last.extras)
139
- if (
140
- now.every(
141
- (s, i) =>
142
- s.size === last.extraStats[i]!.size && s.mtimeMs === last.extraStats[i]!.mtimeMs,
143
- )
144
- ) {
153
+ if (now.every((s, i) => s === last.extraStats[last.extras[i]!])) {
145
154
  return last.digests
146
155
  }
147
156
  }
@@ -152,28 +161,22 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
152
161
  // re-hashes the files the memo names and never parses to learn them.
153
162
  const memo = await readMemo(memoFile, version, lockHash)
154
163
  let extras: Record<string, string> = {}
164
+ let extraStats: Record<string, string> = {}
155
165
  let importers: ReadonlyMap<string, string> | undefined
156
166
  if (memo !== undefined) {
157
- const now = Object.fromEntries(
158
- await Promise.all(
159
- Object.keys(memo.extras).map(
160
- async (rel) => [rel, await hashExtra(workspaceRoot, rel)] as const,
161
- ),
162
- ),
163
- )
164
- if (Object.entries(memo.extras).every(([rel, h]) => now[rel] === h)) {
167
+ const now = await hashExtras(workspaceRoot, Object.keys(memo.extras))
168
+ if (Object.entries(memo.extras).every(([rel, h]) => now.hashes[rel] === h)) {
165
169
  extras = memo.extras
170
+ extraStats = now.stats
166
171
  importers = memo.importers
167
172
  }
168
173
  }
169
174
  if (importers === undefined) {
170
175
  const text = decode(bytes)
171
176
  const rels = [...new Set(options.extraFiles?.(text) ?? [])].sort()
172
- extras = Object.fromEntries(
173
- await Promise.all(
174
- rels.map(async (rel) => [rel, await hashExtra(workspaceRoot, rel)] as const),
175
- ),
176
- )
177
+ const now = await hashExtras(workspaceRoot, rels)
178
+ extras = now.hashes
179
+ extraStats = now.stats
177
180
  importers =
178
181
  scope === 'workspace'
179
182
  ? new Map<string, string>()
@@ -191,13 +194,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
191
194
  ? lockHash
192
195
  : xxh3hex([lockHash, ...rels.map((r) => `${r}\0${extras[r]}`)].join('\0'))
193
196
  const digests: Digests = { lock, importers }
194
- seen.set(workspaceRoot, {
195
- size: st.size,
196
- mtimeMs: st.mtimeMs,
197
- extras: rels,
198
- extraStats: await statsOf(workspaceRoot, rels),
199
- digests,
200
- })
197
+ seen.set(workspaceRoot, { identity, extras: rels, extraStats, digests })
201
198
  return digests
202
199
  }
203
200
  const loadOnce = (ctx: KeyHookContext): Promise<Digests> => {
@@ -270,8 +267,7 @@ function digestFor(importers: ReadonlyMap<string, string>, importer: string): st
270
267
 
271
268
  /** The lockfile's importer path for a project directory: `.` for the root, POSIX otherwise. */
272
269
  function importerOf(workspaceRoot: string, projectDir: string): string {
273
- const rel = path.relative(workspaceRoot, projectDir)
274
- return rel === '' ? '.' : rel.split(path.sep).join('/')
270
+ return relPosix(workspaceRoot, projectDir) || '.'
275
271
  }
276
272
 
277
273
  async function readMemo(