@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.
- package/launcher.cjs +9 -2
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +18 -7
- package/src/cache/cache.ts +137 -101
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +33 -17
- package/src/cache/inputs.ts +157 -54
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/tar-stream.ts +4 -2
- package/src/cli/cache.ts +4 -16
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +56 -6
- package/src/cli/help.ts +27 -8
- package/src/cli/info.ts +4 -6
- package/src/cli/init.ts +4 -4
- package/src/cli/last.ts +49 -20
- package/src/cli/lock.ts +3 -3
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +80 -50
- package/src/cli/select.ts +44 -5
- package/src/cli/show.ts +47 -9
- package/src/cli/upgrade.ts +2 -3
- package/src/cli/watch-filter.ts +21 -7
- package/src/cli/watch-fs.ts +32 -8
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch.ts +40 -15
- package/src/cli/why.ts +4 -13
- package/src/cli/workspace-config.ts +1 -1
- package/src/config.ts +5 -5
- package/src/exec/env.ts +8 -5
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +5 -1
- package/src/exec/runner.ts +126 -47
- package/src/exec/sandbox-deny-scan.ts +2 -2
- package/src/exec/sandbox-paths.ts +11 -5
- package/src/exec/sandbox-runtime.ts +89 -23
- package/src/exec/sandbox-violations.ts +23 -9
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +14 -4
- package/src/graph/task-graph.ts +3 -2
- package/src/index.ts +9 -0
- package/src/orchestrator/affected-tasks.ts +34 -10
- package/src/orchestrator/doctor.ts +2 -1
- package/src/orchestrator/execute-task.ts +43 -10
- package/src/orchestrator/hit-restore.ts +9 -5
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +45 -49
- package/src/orchestrator/logger.ts +4 -1
- package/src/orchestrator/miss-save.ts +10 -3
- package/src/orchestrator/placement.ts +17 -3
- package/src/orchestrator/plan.ts +25 -1
- package/src/orchestrator/plugin-host.ts +41 -3
- package/src/orchestrator/plugin.ts +11 -14
- package/src/orchestrator/projects.ts +18 -9
- package/src/orchestrator/run-context.ts +2 -2
- package/src/orchestrator/run-lock.ts +14 -4
- package/src/orchestrator/run.ts +94 -16
- package/src/orchestrator/sandbox-request.ts +6 -10
- 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 +47 -19
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-eval.ts +4 -0
- package/src/workspace/config-imports.ts +2 -2
- package/src/workspace/config-schema.ts +47 -10
- package/src/workspace/filter.ts +5 -4
- package/src/workspace/migrate-scripts.ts +23 -6
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +89 -2
- 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[
|
|
214
|
-
|
|
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[
|
|
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
|
|
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], {
|
|
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
|
})
|
package/src/graph/index.ts
CHANGED
package/src/graph/scheduler.ts
CHANGED
|
@@ -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
|
|
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')
|
|
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
|
-
|
|
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,
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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:
|
|
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(
|
|
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 = {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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) =>
|
|
168
|
+
.map((p) => relPosix(node.projectDir, p))
|
|
169
169
|
.filter((rel) => expectedRels.has(rel) || !isAddition(rel))
|
|
170
170
|
const actualWsRels = actualWsAbs
|
|
171
|
-
.map((p) =>
|
|
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({
|
|
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)
|
|
@@ -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,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
|
|
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
|
-
|
|
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> => {
|
|
@@ -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
|
-
|
|
274
|
-
return rel === '' ? '.' : rel.split(path.sep).join('/')
|
|
270
|
+
return relPosix(workspaceRoot, projectDir) || '.'
|
|
275
271
|
}
|
|
276
272
|
|
|
277
273
|
async function readMemo(
|