@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.
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +2 -2
- package/src/cache/cache.ts +96 -90
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +19 -8
- package/src/cache/inputs.ts +110 -37
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/tar-stream.ts +1 -1
- package/src/cli/cache.ts +1 -11
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +25 -4
- package/src/cli/help.ts +5 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +29 -27
- package/src/cli/select.ts +30 -3
- package/src/cli/show.ts +42 -3
- package/src/cli/watch-filter.ts +14 -1
- package/src/cli/watch-fs.ts +15 -7
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch.ts +18 -11
- package/src/config.ts +5 -5
- package/src/exec/env.ts +5 -0
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +5 -1
- package/src/exec/runner.ts +98 -40
- package/src/exec/sandbox-paths.ts +11 -5
- package/src/exec/sandbox-runtime.ts +20 -4
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +1 -1
- package/src/index.ts +6 -0
- package/src/orchestrator/execute-task.ts +29 -7
- package/src/orchestrator/hit-restore.ts +6 -3
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +43 -46
- package/src/orchestrator/plan.ts +25 -1
- package/src/orchestrator/plugin-host.ts +25 -3
- package/src/orchestrator/plugin.ts +2 -1
- package/src/orchestrator/run.ts +52 -8
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +14 -1
- package/src/util/index.ts +7 -1
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +30 -8
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +48 -6
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +72 -0
- package/src/workspace/workspace.ts +82 -11
package/src/exec/runner.ts
CHANGED
|
@@ -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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* `${#x}` are words), and a newline closes
|
|
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
|
|
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 (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
200
|
-
|
|
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 =
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
82
|
-
*
|
|
83
|
-
* grant
|
|
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
|
-
|
|
98
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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. */
|
package/src/graph/index.ts
CHANGED
package/src/graph/scheduler.ts
CHANGED
|
@@ -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(
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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(
|
|
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
|
|
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
|
-
|
|
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<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
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
|
-
|
|
117
|
-
mtimeMs: number
|
|
131
|
+
identity: string
|
|
118
132
|
extras: readonly string[]
|
|
119
|
-
extraStats:
|
|
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
|
|
142
|
+
let identity: string
|
|
129
143
|
try {
|
|
130
|
-
|
|
144
|
+
identity = identityOf(await stat(full))
|
|
131
145
|
} catch {
|
|
132
146
|
const digests: Digests = { lock: '', importers: new Map() }
|
|
133
|
-
seen.set(workspaceRoot, {
|
|
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.
|
|
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.
|
|
158
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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> => {
|