@vzn/vx 0.0.594 → 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 (54) hide show
  1. package/package.json +5 -5
  2. package/src/bin.ts +26 -11
  3. package/src/cache/archive.ts +2 -2
  4. package/src/cache/cache.ts +96 -90
  5. package/src/cache/config-evals.ts +19 -4
  6. package/src/cache/git-inputs.ts +19 -8
  7. package/src/cache/inputs.ts +110 -37
  8. package/src/cache/layered-cache.ts +25 -2
  9. package/src/cache/tar-stream.ts +1 -1
  10. package/src/cli/cache.ts +1 -11
  11. package/src/cli/completions.ts +42 -6
  12. package/src/cli/foreign-flags.ts +25 -4
  13. package/src/cli/help.ts +5 -3
  14. package/src/cli/init.ts +1 -1
  15. package/src/cli/plan-format.ts +9 -0
  16. package/src/cli/plugin-templates.ts +2 -2
  17. package/src/cli/run.ts +29 -27
  18. package/src/cli/select.ts +30 -3
  19. package/src/cli/show.ts +42 -3
  20. package/src/cli/watch-filter.ts +14 -1
  21. package/src/cli/watch-fs.ts +15 -7
  22. package/src/cli/watch-judge.ts +1 -1
  23. package/src/cli/watch.ts +18 -11
  24. package/src/config.ts +5 -5
  25. package/src/exec/env.ts +5 -0
  26. package/src/exec/index.ts +1 -0
  27. package/src/exec/proc-sample.ts +5 -1
  28. package/src/exec/runner.ts +98 -40
  29. package/src/exec/sandbox-paths.ts +11 -5
  30. package/src/exec/sandbox-runtime.ts +20 -4
  31. package/src/graph/index.ts +1 -0
  32. package/src/graph/scheduler.ts +1 -1
  33. package/src/index.ts +6 -0
  34. package/src/orchestrator/execute-task.ts +29 -7
  35. package/src/orchestrator/hit-restore.ts +6 -3
  36. package/src/orchestrator/local-shortcircuit.ts +4 -1
  37. package/src/orchestrator/lockfile-claim.ts +43 -46
  38. package/src/orchestrator/plan.ts +25 -1
  39. package/src/orchestrator/plugin-host.ts +25 -3
  40. package/src/orchestrator/plugin.ts +2 -1
  41. package/src/orchestrator/run.ts +52 -8
  42. package/src/orchestrator/signals.ts +5 -2
  43. package/src/orchestrator/summary.ts +14 -1
  44. package/src/util/index.ts +7 -1
  45. package/src/util/secret-mask.ts +36 -0
  46. package/src/util/settle.ts +15 -0
  47. package/src/workspace/affected.ts +30 -8
  48. package/src/workspace/config-cache.ts +25 -4
  49. package/src/workspace/config-imports.ts +1 -1
  50. package/src/workspace/config-schema.ts +48 -6
  51. package/src/workspace/migrate-scripts.ts +1 -1
  52. package/src/workspace/package-graph.ts +15 -3
  53. package/src/workspace/project-loader.ts +72 -0
  54. package/src/workspace/workspace.ts +82 -11
@@ -12,6 +12,7 @@ import {
12
12
  isOutOfFds,
13
13
  killGraceMs,
14
14
  OUT_OF_FDS_HINT,
15
+ type SecretMask,
15
16
  } from '../util/index.js'
16
17
  import {
17
18
  closeSignalChannel,
@@ -112,30 +113,49 @@ export function shellQuote(arg: string): string {
112
113
  }
113
114
 
114
115
  /**
115
- * Where a shell comment still open at the end of `command` starts, or -1.
116
- * A `#` opens one only unquoted and at the start of a word (`a#b`, `$#` and
117
- * `${#x}` are words), and a newline closes it.
116
+ * One pass over `command` as sh reads it. `comment`: where a comment still
117
+ * open at the end starts, or -1 — a `#` opens one only unquoted and at the
118
+ * start of a word (`a#b`, `$#` and `${#x}` are words), and a newline closes
119
+ * it. `body`: per line, is it a heredoc's body or terminator (`<<X` /
120
+ * `<<-'X'` / `<<\X` … `X`). One scan, because each needs the other: a
121
+ * `<<X` in a comment or quotes opens no heredoc, and a quote in a body
122
+ * opens nothing (X-110).
118
123
  */
119
- function trailingCommentStart(command: string): number {
124
+ function shellShape(command: string): { comment: number; body: boolean[] } {
125
+ const lines = command.split('\n')
126
+ const body = lines.map(() => false)
127
+ const pending: Array<{ delim: string; tabs: boolean }> = []
120
128
  let quote = ''
121
129
  let comment = -1
122
130
  let atWordStart = true
131
+ let line = 0
123
132
  for (let i = 0; i < command.length; i++) {
124
133
  const c = command[i]!
125
- if (comment >= 0) {
126
- if (c === '\n') {
127
- comment = -1
128
- atWordStart = true
134
+ if (c === '\n') {
135
+ line++
136
+ comment = -1
137
+ if (quote !== '') continue
138
+ atWordStart = true
139
+ // A heredoc's body starts on the line after its `<<`.
140
+ while (pending.length > 0 && line < lines.length) {
141
+ const text = lines[line]!
142
+ body[line] = true
143
+ const open = pending[0]!
144
+ if ((open.tabs ? text.replace(/^\t+/, '') : text) === open.delim) pending.shift()
145
+ i += text.length + 1
146
+ line++
129
147
  }
130
148
  continue
131
149
  }
150
+ if (comment >= 0) continue
132
151
  if (quote === "'") {
133
152
  if (c === "'") quote = ''
134
153
  continue
135
154
  }
136
155
  if (c === '\\') {
156
+ if (command[i + 1] === '\n') line++
157
+ else atWordStart = false
137
158
  i++
138
- atWordStart = false
139
159
  continue
140
160
  }
141
161
  if (quote === '"') {
@@ -151,25 +171,18 @@ function trailingCommentStart(command: string): number {
151
171
  comment = i
152
172
  continue
153
173
  }
174
+ if (c === '<' && command[i - 1] !== '<') {
175
+ const m = /^<<(-?)[ \t]*\\?(['"]?)([A-Za-z_]\w*)\2/.exec(command.slice(i, i + 256))
176
+ if (m !== null) {
177
+ pending.push({ delim: m[3]!, tabs: m[1] === '-' })
178
+ i += m[0].length - 1
179
+ atWordStart = false
180
+ continue
181
+ }
182
+ }
154
183
  atWordStart = /[\s;&|()<>]/.test(c)
155
184
  }
156
- return comment
157
- }
158
-
159
- /** Per line: is it a heredoc's body or terminator (`<<X` / `<<-'X'` … `X`)? */
160
- function heredocLines(lines: readonly string[]): boolean[] {
161
- const pending: Array<{ delim: string; tabs: boolean }> = []
162
- return lines.map((line) => {
163
- const open = pending[0]
164
- if (open !== undefined) {
165
- if ((open.tabs ? line.replace(/^\t+/, '') : line) === open.delim) pending.shift()
166
- return true
167
- }
168
- for (const m of line.matchAll(/(?<!<)<<(?!<)(-?)\s*(['"]?)([A-Za-z_]\w*)\2/g)) {
169
- pending.push({ delim: m[3]!, tabs: m[1] === '-' })
170
- }
171
- return false
172
- })
185
+ return { comment, body }
173
186
  }
174
187
 
175
188
  /**
@@ -183,27 +196,34 @@ export function withForwardArgs(command: string, args: readonly string[] | undef
183
196
  // `X FWD` closed nothing and the args never reached the command (X-12).
184
197
  // They go on the last line that is a command, where `cat <<X FWD` is
185
198
  // still `cat FWD` reading the heredoc.
186
- const lines = command.trimEnd().split('\n')
187
- const body = heredocLines(lines)
188
- if (body[lines.length - 1] === true) {
189
- const last = body.lastIndexOf(false)
199
+ const trimmed = command.trimEnd()
200
+ const lines = trimmed.split('\n')
201
+ const shape = shellShape(trimmed)
202
+ if (shape.body[lines.length - 1] === true) {
203
+ const last = shape.body.lastIndexOf(false)
190
204
  const tail = lines.slice(last + 1).join('\n')
191
205
  return `${withForwardArgs(lines.slice(0, last + 1).join('\n'), args)}\n${tail}`
192
206
  }
193
207
  const quoted = args.map(shellQuote).join(' ')
194
- let comment = trailingCommentStart(command)
208
+ // Of the trimmed command: the newline closing a template literal's last
209
+ // line closed its comment too, and `echo args: # c\n` took the args into
210
+ // the comment (X-110).
211
+ let comment = shape.comment
195
212
  if (comment < 0) {
196
213
  // A command that ends in a newline (a template literal's closing line)
197
214
  // ran the args as a command of their own: `--watch: not found`. Kept
198
215
  // when an odd run of backslashes ends it: that escapes what follows.
199
- const body = command.trimEnd()
200
- const escaped = (/\\+$/.exec(body)?.[0].length ?? 0) % 2 === 1
201
- return `${escaped ? command : body} ${quoted}`
216
+ const escaped = (/\\+$/.exec(trimmed)?.[0].length ?? 0) % 2 === 1
217
+ return `${escaped ? command : trimmed} ${quoted}`
202
218
  }
203
219
  // A comment-only last line after a commented line: the args go before
204
220
  // the earliest comment, or they land inside it (`echo one # c\n# two`).
205
- for (let c = comment; c >= 0; c = trailingCommentStart(command.slice(0, c).trimEnd())) comment = c
206
- return `${command.slice(0, comment).trimEnd()} ${quoted} ${command.slice(comment)}`
221
+ for (let c = comment; c >= 0; c = shellShape(trimmed.slice(0, c).trimEnd()).comment) comment = c
222
+ // What precedes the comment may end in a heredoc: the args go where they
223
+ // would with no comment, never on its terminator.
224
+ const head = trimmed.slice(0, comment).trimEnd()
225
+ const gap = trimmed.slice(head.length, comment) || ' '
226
+ return `${withForwardArgs(head, args)}${gap}${trimmed.slice(comment)}`
207
227
  }
208
228
 
209
229
  // Any shell control/expansion character means the wrapping `sh` has real
@@ -348,6 +368,18 @@ const SIGNAL_ALIASES = new Set(['SIGIOT', 'SIGPOLL', 'SIGCLD'])
348
368
  */
349
369
  const TIMEOUT_SIGKILL_GRACE_MS = 2000
350
370
 
371
+ /**
372
+ * Run `fn` one loop turn later, once the child exits already pending are
373
+ * read. A deadline due in the same turn as a child's exit fires BEFORE Bun
374
+ * reaps it: a task that exited 0 inside its timeout, seen late by a busy vx,
375
+ * was SIGTERMed as a zombie and failed as timed out, and a server that died
376
+ * before ready read as a readiness timeout (2026-10-08). A turn later the
377
+ * exit has settled the caller, which clears the returned timer.
378
+ */
379
+ function afterPendingExits(fn: () => void): ReturnType<typeof setTimeout> {
380
+ return setTimeout(fn, 0)
381
+ }
382
+
351
383
  /**
352
384
  * Arm a SIGTERM timeout on a spawned child. Returns a handle whose
353
385
  * `timedOut()` reports whether the timer fired — so the caller can
@@ -368,7 +400,10 @@ export function armTimeout(
368
400
  let firedAt: number | undefined
369
401
  let killTimer: ReturnType<typeof setTimeout> | undefined
370
402
  const graceMs = killGraceMs(TIMEOUT_SIGKILL_GRACE_MS)
371
- const timer = setTimeout(() => {
403
+ let timer = setTimeout(() => {
404
+ timer = afterPendingExits(fire)
405
+ }, timeoutMs)
406
+ function fire(): void {
372
407
  firedAt = Date.now()
373
408
  killTree(proc, 'SIGTERM')
374
409
  // Escalate to SIGKILL after a grace: a child that TRAPS+IGNORES SIGTERM
@@ -379,7 +414,7 @@ export function armTimeout(
379
414
  // the CLI alive.
380
415
  killTimer = setTimeout(() => killTree(proc, 'SIGKILL'), graceMs)
381
416
  killTimer.unref?.()
382
- }, timeoutMs)
417
+ }
383
418
  return {
384
419
  timedOut: () => firedAt !== undefined,
385
420
  settle: async () => {
@@ -680,7 +715,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
680
715
  // a healthy server is never killed by a stale timer.
681
716
  let readyTimer: ReturnType<typeof setTimeout> | undefined
682
717
  if (readyRe && opts.timeoutMs !== undefined) {
683
- readyTimer = setTimeout(() => {
718
+ const giveUp = (): void => {
684
719
  if (readyAt === undefined) {
685
720
  gaveUpAt = Date.now()
686
721
  rejectReady(
@@ -705,6 +740,9 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
705
740
  }, killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
706
741
  killTimer.unref?.()
707
742
  }
743
+ }
744
+ readyTimer = setTimeout(() => {
745
+ readyTimer = afterPendingExits(giveUp)
708
746
  }, opts.timeoutMs)
709
747
  }
710
748
 
@@ -883,6 +921,23 @@ export function droppedOutputLine(dropped: number): string {
883
921
  )
884
922
  }
885
923
 
924
+ /**
925
+ * A captured `text` with `secrets` masked. A value the dropped middle cut
926
+ * left a piece at the head's end and one at the tail's start, which the
927
+ * whole-value mask missed, and the cache entry stored and replayed both.
928
+ */
929
+ export function maskCaptured(text: string, secrets: SecretMask): string {
930
+ const at = text.indexOf('\n[vx] ', CAPTURE_HEAD_CHARS - 1)
931
+ const end = at === -1 ? -1 : text.indexOf('\n', at + 1) + 1
932
+ if (at === -1 || at > CAPTURE_HEAD_CHARS || !DROPPED_LINE.test(text.slice(at, end))) {
933
+ return secrets.mask(text)
934
+ }
935
+ const [head, tail] = secrets.maskCut(text.slice(0, at), text.slice(end))
936
+ return head + text.slice(at, end) + tail
937
+ }
938
+
939
+ const DROPPED_LINE = /^\n\[vx\] [\d.]+ MiB of output not kept — /
940
+
886
941
  const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff
887
942
 
888
943
  /**
@@ -897,7 +952,10 @@ class BoundedCapture {
897
952
  private dropped = 0
898
953
 
899
954
  push(chunk: string): void {
900
- if (this.head.length < CAPTURE_HEAD_CHARS) {
955
+ // Once the tail has begun the head is closed, even a unit short of its
956
+ // bound (the surrogate case below): a later short chunk fit there and
957
+ // was retained ahead of the chunk before it.
958
+ if (this.tailLen === 0 && this.head.length < CAPTURE_HEAD_CHARS) {
901
959
  let room = CAPTURE_HEAD_CHARS - this.head.length
902
960
  if (chunk.length <= room) {
903
961
  this.head += chunk
@@ -78,14 +78,16 @@ export function localBindingOn(c: { localBinding?: boolean | readonly number[] }
78
78
  }
79
79
 
80
80
  /**
81
- * Whether a sandboxed task may read `file`: it lies under a read grant,
82
- * a write grant or a baseline read, by its canonical path. A path no
83
- * grant holds is not there inside the sandbox, which the shell reports
81
+ * Whether a sandboxed task may read `file`: it lies under no denial (the
82
+ * rest of the host is mounted read-only), or under a read grant, a write
83
+ * grant or a baseline read, by its canonical path. A path no grant holds
84
+ * under a denial is not there inside the sandbox, which the shell reports
84
85
  * as "not found" with no denial a trace can see (an `execve`).
85
86
  */
86
87
  export function sandboxReads(
87
88
  sandbox: {
88
89
  readonly baseAllowRead: readonly string[]
90
+ readonly baseDenyRead: readonly string[]
89
91
  readonly config: {
90
92
  readonly allowRead: readonly string[]
91
93
  readonly allowWrite: readonly string[]
@@ -94,7 +96,11 @@ export function sandboxReads(
94
96
  file: string,
95
97
  ): boolean {
96
98
  const real = toRealPath(file)
97
- return [...sandbox.baseAllowRead, ...sandbox.config.allowRead, ...sandbox.config.allowWrite].some(
98
- (g) => atOrUnder(real, toRealPath(g)),
99
+ const under = (g: string): boolean => atOrUnder(real, toRealPath(g))
100
+ return (
101
+ !sandbox.baseDenyRead.some(under) ||
102
+ [...sandbox.baseAllowRead, ...sandbox.config.allowRead, ...sandbox.config.allowWrite].some(
103
+ under,
104
+ )
99
105
  )
100
106
  }
@@ -279,9 +279,16 @@ export function dependencyReason(errors: readonly string[]): string {
279
279
  * listen" on macOS and "Failed to create bridge sockets after 5 attempts"
280
280
  * on Linux (its retry loop swallows the code), neither naming the
281
281
  * directory (2026-09-16). Checked up front, with room for the sequence.
282
+ * On Linux the longer name is the network bridge's,
283
+ * `claude-http-<16 hex>.sock`, and it is the one that fails there.
282
284
  */
283
285
  export function socketPathRefusal(tmpdir = os.tmpdir()): string | undefined {
284
- const sample = path.join(tmpdir, `srt-mux-${process.pid}-zzz.sock`)
286
+ const sample = path.join(
287
+ tmpdir,
288
+ process.platform === 'linux'
289
+ ? `claude-http-${'0'.repeat(16)}.sock`
290
+ : `srt-mux-${process.pid}-zzz.sock`,
291
+ )
285
292
  const limit = process.platform === 'darwin' ? 103 : 107
286
293
  const length = Buffer.byteLength(sample)
287
294
  if (length <= limit) return undefined
@@ -1246,7 +1253,13 @@ async function wrapIn(
1246
1253
  ...macProfileRules(args.config),
1247
1254
  ...darwinWallRules(args.config, baselines.allowRead),
1248
1255
  ]
1249
- if (rules.length > 0) wrapped = injectProfileRules(wrapped, rules)
1256
+ try {
1257
+ if (rules.length > 0) wrapped = injectProfileRules(wrapped, rules)
1258
+ } catch (err) {
1259
+ releaseBridges(tag)
1260
+ afterCommand(SandboxManager)
1261
+ throw err
1262
+ }
1250
1263
  }
1251
1264
  // Linux: the shell execs bwrap, so bwrap is the spawn itself and its
1252
1265
  // `--die-with-parent` is keyed to vx. Behind a shell that waited on it,
@@ -1383,11 +1396,14 @@ function ownGroupCommand(
1383
1396
  .join(' ')
1384
1397
  const body = shellQuote(`exec ${TRACE_FD}>&-; ${userCommand}`)
1385
1398
  const run = `{ trap - INT QUIT; exec ${setsid ?? ''}${tracer} ${shellQuote(sh)} -c ${body} 3<&-; } & c=$!;`
1399
+ // Bash reports a job a signal killed on its stderr, the task's: a task
1400
+ // whose shell died of SIGKILL printed this whole wrapper (X-111).
1401
+ const wait = `wait "$c" 2>/dev/null`
1386
1402
  if (setsid === undefined) {
1387
- return { command: `${tag0} ${run} wait "$c"`, forwards: false, traced: true }
1403
+ return { command: `${tag0} ${run} ${wait}`, forwards: false, traced: true }
1388
1404
  }
1389
1405
  const watch = `{ IFS= read -r s && kill -s "$s" -- "-$c"; } 2>/dev/null <&3 3<&- &`
1390
- return { command: `${tag0} ${run} ${watch} wait "$c"`, forwards: true, traced: true }
1406
+ return { command: `${tag0} ${run} ${watch} ${wait}`, forwards: true, traced: true }
1391
1407
  }
1392
1408
 
1393
1409
  /** What strace stops on: the reads, and what moves or makes a process's cwd. */
@@ -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,
@@ -969,7 +969,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
969
969
  }
970
970
 
971
971
  /** Persistent tasks nobody asked for whose every dependant is a restore-tier hit. */
972
- function idleServers(
972
+ export function idleServers(
973
973
  nodes: ReadonlyMap<string, TaskNode>,
974
974
  dependents: ReadonlyMap<string, string[]>,
975
975
  restoreTier: ReadonlySet<string> | undefined,
package/src/index.ts CHANGED
@@ -179,6 +179,12 @@ export type {
179
179
  // A remote executor gives a task back to the local floor by rejecting with
180
180
  // this (B-100): core runs it here and says why once.
181
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'
182
188
  export type {
183
189
  ExecuteRequest,
184
190
  ExecuteResult,
@@ -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 {
@@ -534,6 +535,20 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
534
535
  }
535
536
  }
536
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
+ }
537
552
  args.persistentRegistry?.set(node.id, spawn.child)
538
553
  forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
539
554
  if (mayWriteFingerprint(node, args.workspaceRoot)) args.fingerprintWatch?.wrote()
@@ -914,7 +929,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
914
929
  // re-create would stay in a same-project consumer's input set, keeping
915
930
  // that consumer's key unchanged while the file is gone from disk.
916
931
  const endClean = span('miss: clean outputs')
917
- const cleanedRels = await cleanOutputs({ ...cleanArgs, keepGlobRoots: true })
932
+ const cleanedRels = await cleanOutputs(cleanArgs)
918
933
  endClean()
919
934
  args.gitFilesCache?.noteClean(node.id, node.projectDir, cleanedRels)
920
935
  args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
@@ -988,7 +1003,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
988
1003
  })
989
1004
  endExec()
990
1005
  if (secrets !== null)
991
- 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
+ }
992
1011
  // An executor that stopped on the timeout's abort exits non-zero; say
993
1012
  // why, so the frame, the retry line and `timedOut` read as a timeout.
994
1013
  if (timeoutFired && res.exitCode !== 0 && res.timedOut !== true) {
@@ -1051,8 +1070,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1051
1070
  // 258) — and an exit above 128 is a signal's number and nothing
1052
1071
  // about what sent it (259). One frame line names the rule. A spawn that
1053
1072
  // threw ran no shell: its 127 is vx's, and the line would send the
1054
- // reader after a command that exists (A-41).
1055
- 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) {
1056
1076
  const verdict = shellVerdict({
1057
1077
  code,
1058
1078
  command: step.command,
@@ -1203,7 +1223,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1203
1223
  },
1204
1224
  }
1205
1225
  : {}),
1206
- signal: requestSignal(),
1207
1226
  ...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
1208
1227
  ...(inputs !== undefined ? { inputs } : {}),
1209
1228
  ...(cfgCacheable ? { cacheKey: hash } : {}),
@@ -1215,7 +1234,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1215
1234
  outputs: { files: outputs, workspaceFiles: wsOutputs },
1216
1235
  ...(args.terminal === true ? { terminal: true as const } : {}),
1217
1236
  }
1218
- 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() }
1219
1241
  await args.armSandbox?.()
1220
1242
  const sb = await sandboxRequestFor(
1221
1243
  node,
@@ -1227,7 +1249,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1227
1249
  )
1228
1250
  placeholders = sb.placeholders
1229
1251
  withheld = sb.withheld
1230
- return { ...base, sandbox: sb.sandbox }
1252
+ return { ...base, sandbox: sb.sandbox, signal: requestSignal() }
1231
1253
  }
1232
1254
 
1233
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,
@@ -224,11 +223,15 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
224
223
  let cleanedWsRels: string[] = []
225
224
  if (additive) {
226
225
  cleanedRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
227
- await cleanOutputPaths({ projectDir: node.projectDir, rels: cleanedRels })
226
+ await cleanOutputPaths({ projectDir: node.projectDir, rels: cleanedRels, outputs })
228
227
  cleanedWsRels = hit.outputFiles
229
228
  .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
230
229
  .map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length))
231
- await cleanOutputPaths({ projectDir: args.workspaceRoot, rels: cleanedWsRels })
230
+ await cleanOutputPaths({
231
+ projectDir: args.workspaceRoot,
232
+ rels: cleanedWsRels,
233
+ outputs: wsOutputs,
234
+ })
232
235
  } else {
233
236
  if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
234
237
  if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
@@ -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,8 +9,8 @@
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'
@@ -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> => {