@vzn/vx 0.0.50 → 0.0.51
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/README.md +38 -31
- package/package.json +5 -5
- package/src/bin.ts +1 -1
- package/src/cache/archive.ts +18 -6
- package/src/cache/cache.ts +150 -67
- package/src/cache/chained-cache.ts +9 -2
- package/src/cache/file-hashes.ts +32 -3
- package/src/cache/git-inputs.ts +96 -11
- package/src/cache/index.ts +1 -0
- package/src/cache/layer.ts +28 -3
- package/src/cache/layered-cache.ts +7 -2
- package/src/cache/output-index.ts +16 -7
- package/src/cache/run-history.ts +5 -1
- package/src/cache/zstd.ts +40 -8
- package/src/cli/cache.ts +35 -9
- package/src/cli/completions.ts +10 -7
- package/src/cli/help.ts +48 -4
- package/src/cli/index.ts +5 -3
- package/src/cli/info.ts +1 -1
- package/src/cli/plugin-commands.ts +10 -2
- package/src/cli/run.ts +1 -0
- package/src/cli/select.ts +29 -2
- package/src/cli/upgrade.ts +110 -22
- package/src/cli/watch.ts +35 -18
- package/src/config.ts +18 -5
- package/src/exec/executor.ts +3 -2
- package/src/exec/index.ts +1 -1
- package/src/exec/runner.ts +102 -24
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-runtime.ts +149 -40
- package/src/exec/sandbox-violations.ts +42 -10
- package/src/graph/scheduler.ts +11 -5
- package/src/graph/task-graph.ts +96 -12
- package/src/orchestrator/admission.ts +72 -17
- package/src/orchestrator/execute-task.ts +39 -25
- package/src/orchestrator/hit-restore.ts +7 -1
- package/src/orchestrator/miss-save.ts +29 -1
- package/src/orchestrator/placement.ts +23 -3
- package/src/orchestrator/plugin-host.ts +1 -1
- package/src/orchestrator/plugin.ts +9 -1
- package/src/orchestrator/prepare.ts +1 -0
- package/src/orchestrator/run.ts +75 -12
- package/src/orchestrator/sandbox-request.ts +19 -10
- package/src/orchestrator/summary.ts +26 -15
- package/src/orchestrator/task-hash.ts +7 -2
- package/src/orchestrator/telemetry.ts +14 -4
- package/src/util/paths.ts +16 -9
- package/src/workspace/affected.ts +201 -38
- package/src/workspace/config-eval.ts +4 -1
- package/src/workspace/config-schema.ts +70 -10
- package/src/workspace/fingerprint.ts +6 -0
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +53 -1
- package/src/workspace/workspace.ts +54 -14
|
@@ -25,32 +25,87 @@ import { executeTask, type ExecuteArgs } from './execute-task.js'
|
|
|
25
25
|
import type { ShortCircuit } from './local-shortcircuit.js'
|
|
26
26
|
import { computeTaskHash, type ComputeHashArgs } from './task-hash.js'
|
|
27
27
|
|
|
28
|
+
/** What `taintTracker` answers per dispatch, and what it learns per finish. */
|
|
29
|
+
export interface TaintTracker {
|
|
30
|
+
/** Whether `node`, dispatched with these `upstream` outcomes, builds on bytes nothing vouches for. */
|
|
31
|
+
judge: (node: TaskNode, upstream: TaskOutcome[]) => boolean
|
|
32
|
+
/** Every settled outcome, the scheduler's `onFinish`. */
|
|
33
|
+
settled: (outcome: TaskOutcome) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const UNTAINTED: TaintTracker = { judge: () => false, settled: () => {} }
|
|
37
|
+
|
|
28
38
|
/**
|
|
29
39
|
* Records which tasks ran behind a failure, or on the key of a dependency
|
|
30
40
|
* that did not run (`seeds`), and answers, per task, whether this one does.
|
|
31
41
|
* Disabled (always `false`, nothing recorded) unless the run's
|
|
32
42
|
* `continueMode` is `'always'` or there is a seed.
|
|
43
|
+
*
|
|
44
|
+
* A task's taint is read from its deps' SETTLED outcomes, not from what it
|
|
45
|
+
* saw at dispatch: a restore-tier hit dispatches before its deps settle,
|
|
46
|
+
* so it saw holes where the failure would be, and a dependent that asked
|
|
47
|
+
* "was that hit tainted?" heard no. Under `--continue=always` the
|
|
48
|
+
* dependent then saved the partial tree on its healthy key, and the next
|
|
49
|
+
* healthy run replayed it (C-1). A dependent is released only once the
|
|
50
|
+
* hit's own deps have settled too (scheduler.ts, item 963), so by the time
|
|
51
|
+
* anyone asks, the answer is complete.
|
|
33
52
|
*/
|
|
34
53
|
export function taintTracker(
|
|
35
54
|
continueAlways: boolean,
|
|
36
55
|
seeds: ReadonlySet<string>,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
56
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
57
|
+
): TaintTracker {
|
|
58
|
+
if (!continueAlways && seeds.size === 0) return UNTAINTED
|
|
59
|
+
const outcomes = new Map<string, TaskOutcome>()
|
|
60
|
+
const memo = new Map<string, boolean>()
|
|
61
|
+
const failed = (u: TaskOutcome): boolean =>
|
|
62
|
+
continueAlways && (u.status === 'failed' || u.status === 'aborted' || u.status === 'skipped')
|
|
63
|
+
const through = (u: TaskOutcome | undefined): boolean =>
|
|
64
|
+
u !== undefined && (failed(u) || taintOf(u.node.id))
|
|
65
|
+
// Post-order over the settled deps with an explicit stack: a chain of
|
|
66
|
+
// restore-tier hits is judged in one ask, and a recursion would take a
|
|
67
|
+
// frame per hop. Memoized once every dep has settled; until then the
|
|
68
|
+
// answer may still grow.
|
|
69
|
+
const taintOf = (root: string): boolean => {
|
|
70
|
+
const known = memo.get(root)
|
|
71
|
+
if (known !== undefined) return known
|
|
72
|
+
const local = new Map<string, boolean>()
|
|
73
|
+
const valueOf = (id: string): boolean | undefined => memo.get(id) ?? local.get(id)
|
|
74
|
+
const stack = [root]
|
|
75
|
+
while (stack.length > 0) {
|
|
76
|
+
const id = stack[stack.length - 1]!
|
|
77
|
+
if (valueOf(id) !== undefined) {
|
|
78
|
+
stack.pop()
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
const deps = seeds.has(id) ? [] : (nodes.get(id)?.deps ?? [])
|
|
82
|
+
const next = deps.find((d) => {
|
|
83
|
+
const u = outcomes.get(d)
|
|
84
|
+
return u !== undefined && !failed(u) && valueOf(d) === undefined
|
|
85
|
+
})
|
|
86
|
+
if (next !== undefined) {
|
|
87
|
+
stack.push(next)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
stack.pop()
|
|
91
|
+
const taint =
|
|
92
|
+
seeds.has(id) ||
|
|
93
|
+
deps.some((d) => {
|
|
94
|
+
const u = outcomes.get(d)
|
|
95
|
+
return u !== undefined && (failed(u) || valueOf(d) === true)
|
|
96
|
+
})
|
|
97
|
+
local.set(id, taint)
|
|
98
|
+
if (taint || deps.every((d) => outcomes.has(d))) memo.set(id, taint)
|
|
99
|
+
}
|
|
100
|
+
return valueOf(root)!
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
// A restore-tier task may run before its deps and see holes here; it
|
|
104
|
+
// never saves anyway (a hit restores), so a hole is not taint.
|
|
105
|
+
judge: (node, upstream) => seeds.has(node.id) || upstream.some(through),
|
|
106
|
+
settled: (outcome) => {
|
|
107
|
+
outcomes.set(outcome.node.id, outcome)
|
|
108
|
+
},
|
|
54
109
|
}
|
|
55
110
|
}
|
|
56
111
|
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
VX_RUN_TASK_ENV,
|
|
19
19
|
VX_RUN_WORKSPACE_ENV,
|
|
20
20
|
runPersistent,
|
|
21
|
-
|
|
21
|
+
withForwardArgs,
|
|
22
22
|
releaseBridges,
|
|
23
23
|
wrapSandboxedCommand,
|
|
24
24
|
signalExitCode,
|
|
@@ -260,16 +260,12 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
260
260
|
const env = taskEnv(node, step, args.workspaceRoot)
|
|
261
261
|
const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
262
262
|
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
// the
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
? step.command
|
|
270
|
-
: effectiveForwardArgs.length > 0
|
|
271
|
-
? step.command + ' ' + effectiveForwardArgs.map(shellQuote).join(' ')
|
|
272
|
-
: step.command
|
|
263
|
+
// The args after `--` reach a server as they reach any task. A readyWhen
|
|
264
|
+
// server once got none — "so the matcher sees the unmodified output",
|
|
265
|
+
// though the args change the command, not what the matcher reads — and
|
|
266
|
+
// `vx run dev -- --port 4000` on the documented Vite task dropped the
|
|
267
|
+
// port in silence (item 1060).
|
|
268
|
+
const plainCommand = withForwardArgs(step.command, effectiveForwardArgs)
|
|
273
269
|
// A persistent task declaring `exec.sandbox` runs inside it like any
|
|
274
270
|
// other: the same grants, the same walls. What it cannot have is the
|
|
275
271
|
// violation REPORT — that reads the trace after the child exits, and a
|
|
@@ -616,6 +612,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
616
612
|
let attempt = 0
|
|
617
613
|
let result: ExecuteResult
|
|
618
614
|
let effectiveExitCode: number
|
|
615
|
+
// What THIS run spent on the task, every attempt (`TaskOutcome.durationMs`):
|
|
616
|
+
// the last attempt alone read `max 407ms` beside a footer of 837 (item
|
|
617
|
+
// 1101). A saved entry keeps the attempt that produced it, what a hit saves.
|
|
618
|
+
let spentMs = 0
|
|
619
619
|
|
|
620
620
|
// One task attempt: clean the declared outputs (before EVERY attempt — so a
|
|
621
621
|
// stale prior-build artifact can't survive into a fresh run, and a failed
|
|
@@ -716,9 +716,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
716
716
|
// whole contract. `userSandbox` is the only way a task is sandboxed,
|
|
717
717
|
// so this reads as "sandboxed and it tripped".
|
|
718
718
|
// A child we SIGTERMed for exceeding the timeout is a genuine failure —
|
|
719
|
-
// stream a clear line so the 143 exit reads as a timeout.
|
|
719
|
+
// stream a clear line so the 143 exit reads as a timeout. One that outlived
|
|
720
|
+
// the grace died of the SIGKILL, and the line said SIGTERM over an exit
|
|
721
|
+
// 137 (item 1063).
|
|
720
722
|
if (res.timedOut) {
|
|
721
|
-
|
|
723
|
+
const how = res.signal === 'SIGKILL' ? 'SIGKILL after the SIGTERM grace' : 'SIGTERM'
|
|
724
|
+
log.taskStderr(node, `\n[vx] timed out after ${effectiveTimeout}ms — killed (${how})\n`)
|
|
722
725
|
// Force a non-zero classification even if the child TRAPPED SIGTERM and
|
|
723
726
|
// still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
|
|
724
727
|
// pattern). Without this a timed-out task is classified `success` and its
|
|
@@ -781,25 +784,36 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
781
784
|
if (writesFingerprint) args.fingerprintWatch?.wrote()
|
|
782
785
|
result = a.result
|
|
783
786
|
effectiveExitCode = a.exitCode
|
|
787
|
+
spentMs += result.durationMs
|
|
784
788
|
|
|
785
789
|
// An attempt that ended in a shutdown never finished on its own terms —
|
|
786
790
|
// it is aborted, so it is neither cached, counted, shown, nor RETRIED.
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
//
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
791
|
+
// The RUN stopping is what says so. A child that traps the forwarded
|
|
792
|
+
// SIGINT and exits 0 was cached as a success with its partial outputs,
|
|
793
|
+
// one that exits 1 was retried after Ctrl-C, and one SIGKILLed at the
|
|
794
|
+
// end of the grace read as an OOM failure (item 962). A child that died
|
|
795
|
+
// of SIGINT / SIGTERM while the run was NOT stopping (a supervisor, a
|
|
796
|
+
// `kill` from another shell) is a failure: it was labelled a shutdown
|
|
797
|
+
// abort, never retried, its output hidden, and fail-fast never tripped
|
|
798
|
+
// (item 1100). A terminal's Ctrl-C reaches the child and vx together,
|
|
799
|
+
// and the child's exit can be seen before vx's own handler has run, so
|
|
800
|
+
// a signal death waits one event-loop turn for a pending handler before
|
|
801
|
+
// it is judged; a timeout's SIGTERM is our own deadline and judged now.
|
|
802
|
+
let shutdown = args.stopSignal?.aborted === true
|
|
803
|
+
if (
|
|
804
|
+
!shutdown &&
|
|
805
|
+
(result.signal === 'SIGINT' || result.signal === 'SIGTERM') &&
|
|
806
|
+
!result.timedOut
|
|
807
|
+
) {
|
|
808
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
809
|
+
shutdown = args.stopSignal?.aborted === true
|
|
810
|
+
}
|
|
797
811
|
if (shutdown) {
|
|
798
812
|
return {
|
|
799
813
|
node,
|
|
800
814
|
status: 'aborted',
|
|
801
815
|
exitCode: effectiveExitCode,
|
|
802
|
-
durationMs:
|
|
816
|
+
durationMs: spentMs,
|
|
803
817
|
hash,
|
|
804
818
|
wallclockStartNs,
|
|
805
819
|
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
@@ -977,7 +991,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
977
991
|
node,
|
|
978
992
|
status: effectiveExitCode === 0 ? 'success' : 'failed',
|
|
979
993
|
exitCode: effectiveExitCode,
|
|
980
|
-
durationMs:
|
|
994
|
+
durationMs: spentMs,
|
|
981
995
|
hash,
|
|
982
996
|
...(attempt > 1 ? { attempts: attempt } : {}),
|
|
983
997
|
...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import type { TaskOutcome } from '../graph/index.js'
|
|
23
23
|
import { asTrees, span, taskGlob, wholeSubtreePrefixes } from '../util/index.js'
|
|
24
24
|
import type { ExecuteArgs } from './execute-task.js'
|
|
25
|
+
import { entryHolds } from './miss-save.js'
|
|
25
26
|
|
|
26
27
|
export interface RestoreHitArgs {
|
|
27
28
|
args: ExecuteArgs
|
|
@@ -232,7 +233,12 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
232
233
|
// ~75 ms of the no-op's CPU (2026-09-11). A caller with no list gets
|
|
233
234
|
// no snapshot (item 637: recording here was that same refusal).
|
|
234
235
|
if (dirPrefixes !== null && args.outputDirSnapshots !== undefined) {
|
|
235
|
-
args.outputDirSnapshots.push({
|
|
236
|
+
args.outputDirSnapshots.push({
|
|
237
|
+
hash,
|
|
238
|
+
projectDir: node.projectDir,
|
|
239
|
+
prefixes: dirPrefixes,
|
|
240
|
+
holds: entryHolds(node),
|
|
241
|
+
})
|
|
236
242
|
}
|
|
237
243
|
// Restored outputs changed the project's tree — but on this
|
|
238
244
|
// path we know the EXACT changed paths (wiped declared
|
|
@@ -13,9 +13,10 @@ import {
|
|
|
13
13
|
type GitFilesCache,
|
|
14
14
|
resolveOutputs,
|
|
15
15
|
resolveWorkspaceOutputs,
|
|
16
|
+
WORKSPACE_OUTPUT_PREFIX,
|
|
16
17
|
} from '../cache/index.js'
|
|
17
18
|
import type { TaskNode } from '../graph/index.js'
|
|
18
|
-
import { span, wholeSubtreePrefixes } from '../util/index.js'
|
|
19
|
+
import { asTrees, span, taskGlob, wholeSubtreePrefixes } from '../util/index.js'
|
|
19
20
|
import type { Logger } from './logger.js'
|
|
20
21
|
import type { TaskInputComponent } from './task-hash.js'
|
|
21
22
|
|
|
@@ -24,6 +25,32 @@ export interface OutputDirSnapshot {
|
|
|
24
25
|
hash: string
|
|
25
26
|
projectDir: string
|
|
26
27
|
prefixes: readonly string[]
|
|
28
|
+
/** Whether the files the run-end walk saw are the entry's rows (`recordOutputDirs`). */
|
|
29
|
+
holds: (files: readonly string[], rows: ReadonlyArray<{ path: string }>) => boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `holds` of a run-end snapshot: the files under the prefixes are the
|
|
34
|
+
* entry's project rows, by the rule the next hit's walk applies. An
|
|
35
|
+
* additive task's rows need only be present (the upstream's files sit
|
|
36
|
+
* beside them); an upstream's tree may also hold what a dependant's glob
|
|
37
|
+
* adds. A stray a later task or another process wrote there before run
|
|
38
|
+
* end refuses the snapshot, and the next hit walks and restores
|
|
39
|
+
* (item 1087). The run reads every snapshot's rows in one batch.
|
|
40
|
+
*/
|
|
41
|
+
export function entryHolds(
|
|
42
|
+
node: TaskNode,
|
|
43
|
+
): (files: readonly string[], rows: ReadonlyArray<{ path: string }>) => boolean {
|
|
44
|
+
return (files, rows) => {
|
|
45
|
+
const expected = new Set(
|
|
46
|
+
rows.map((r) => r.path).filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX)),
|
|
47
|
+
)
|
|
48
|
+
const seen = new Set(files)
|
|
49
|
+
if ((node.addsToOutputsOf?.length ?? 0) > 0) return [...expected].every((p) => seen.has(p))
|
|
50
|
+
const added = (node.outputsAddedToBy ?? []).flatMap((g) => asTrees([g])).map(taskGlob)
|
|
51
|
+
const kept = files.filter((f) => expected.has(f) || !added.some((g) => g.match(f)))
|
|
52
|
+
return kept.length === expected.size && kept.every((f) => expected.has(f))
|
|
53
|
+
}
|
|
27
54
|
}
|
|
28
55
|
|
|
29
56
|
export interface SaveMissArgs {
|
|
@@ -151,6 +178,7 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<void>
|
|
|
151
178
|
hash: a.hash,
|
|
152
179
|
projectDir: node.projectDir,
|
|
153
180
|
prefixes: savedDirPrefixes,
|
|
181
|
+
holds: entryHolds(node),
|
|
154
182
|
})
|
|
155
183
|
}
|
|
156
184
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Where each task runs: the placement of a graph over the resolved
|
|
2
2
|
// executors, and the plan-mode view of it. Split from run.ts on 2026-09-10
|
|
3
3
|
// (pure motion). A task is pinned to this machine when it is persistent,
|
|
4
|
-
// depends on a persistent task, is sandboxed,
|
|
4
|
+
// depends on a persistent task, is sandboxed, says `exec.remote: false`, or
|
|
5
|
+
// folds a runtime probe into its key;
|
|
5
6
|
// everything else asks the executors in declaration order, and the local
|
|
6
7
|
// floor takes what nothing claimed.
|
|
7
8
|
|
|
@@ -19,7 +20,8 @@ import type { prepareRun } from './prepare.js'
|
|
|
19
20
|
* submitter), declares `exec.sandbox` (the sandbox is this machine's
|
|
20
21
|
* machinery — a worker has none of it, and a boundary "verified" where it
|
|
21
22
|
* is not enforced passes vacuously), or declares `exec.remote: false`. A
|
|
22
|
-
* dependant of a pinned task is pinned with it.
|
|
23
|
+
* dependant of a pinned task is pinned with it. A task whose key folds a
|
|
24
|
+
* runtime probe is pinned alone (`withProbedRuntime`).
|
|
23
25
|
*/
|
|
24
26
|
export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
|
|
25
27
|
const pinned = new Set<string>()
|
|
@@ -32,7 +34,7 @@ export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
|
|
|
32
34
|
pinned.add(node.id)
|
|
33
35
|
}
|
|
34
36
|
}
|
|
35
|
-
if (pinned.size === 0) return pinned
|
|
37
|
+
if (pinned.size === 0) return withProbedRuntime(nodes, pinned)
|
|
36
38
|
// Pinning flows up the dependant edges from what pins itself, one walk on
|
|
37
39
|
// an explicit stack: a chain is as deep as the graph, and a recursion
|
|
38
40
|
// per edge threw `RangeError` on the 50,000 the builder takes (item 737).
|
|
@@ -52,6 +54,24 @@ export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
|
|
|
52
54
|
stack.push(up)
|
|
53
55
|
}
|
|
54
56
|
}
|
|
57
|
+
return withProbedRuntime(nodes, pinned)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A key that folds `cache.inputs.runtime` / `workspaceRuntime` holds what
|
|
62
|
+
* THIS machine answered (`node -v`); a worker runs its own runtime, which no
|
|
63
|
+
* executor can prove equal, and its output saved under this key is a stale
|
|
64
|
+
* hit here (C-2). Such a task runs here. Its dependants are not pinned with
|
|
65
|
+
* it: they fold the probe through its input key, and their own output does
|
|
66
|
+
* not depend on this machine's runtime.
|
|
67
|
+
*/
|
|
68
|
+
function withProbedRuntime(nodes: Map<string, TaskNode>, pinned: Set<string>): Set<string> {
|
|
69
|
+
for (const node of nodes.values()) {
|
|
70
|
+
const inputs = node.config.cache?.inputs
|
|
71
|
+
if ((inputs?.runtime?.length ?? 0) > 0 || (inputs?.workspaceRuntime?.length ?? 0) > 0) {
|
|
72
|
+
pinned.add(node.id)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
55
75
|
return pinned
|
|
56
76
|
}
|
|
57
77
|
|
|
@@ -224,7 +224,15 @@ export interface PluginCommand {
|
|
|
224
224
|
run(argv: readonly string[], ctx: CommandContext): number | Promise<number>
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
export interface CommandContext extends BaseContext {
|
|
227
|
+
export interface CommandContext extends BaseContext {
|
|
228
|
+
/**
|
|
229
|
+
* The worker count a `vx run` here uses unless `--concurrency` says
|
|
230
|
+
* otherwise: `vx.workspace`'s `concurrency`, else what the machine allows.
|
|
231
|
+
* A verb that budgets a run (`vx history`) reads it here; its own
|
|
232
|
+
* `machineParallelism()` said 4 cores beside `concurrency: 1` (item 1068).
|
|
233
|
+
*/
|
|
234
|
+
readonly concurrency: number
|
|
235
|
+
}
|
|
228
236
|
|
|
229
237
|
/** `config` runs before the cache dir is known — it may be what the hook changes. */
|
|
230
238
|
export interface WorkspaceHookContext {
|
|
@@ -405,6 +405,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
405
405
|
projects,
|
|
406
406
|
packageGraph,
|
|
407
407
|
requested,
|
|
408
|
+
workspaceRoot,
|
|
408
409
|
...(projects.size < projectsWithConfigs.length
|
|
409
410
|
? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
|
|
410
411
|
: {}),
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -7,7 +7,12 @@ import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/in
|
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { type CacheLayer, type CachePolicy, FULL_CACHE_POLICY } from '../cache/index.js'
|
|
9
9
|
import { VERSION } from '../version.js'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
resetSandbox,
|
|
12
|
+
signalExitCode,
|
|
13
|
+
VX_RUN_TASK_ENV,
|
|
14
|
+
VX_RUN_WORKSPACE_ENV,
|
|
15
|
+
} from '../exec/index.js'
|
|
11
16
|
import { DeferredOutputs } from './deferred-outputs.js'
|
|
12
17
|
import { resolveDownloadModes } from './download-policy.js'
|
|
13
18
|
import type { TaskExecutor } from '../exec/index.js'
|
|
@@ -173,6 +178,20 @@ export function shouldShortCircuit(
|
|
|
173
178
|
return nodes.size > 0
|
|
174
179
|
}
|
|
175
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The command line a run hands to its telemetry sinks, with what follows
|
|
183
|
+
* `--` counted, not quoted. Those are the task's arguments,
|
|
184
|
+
* often a token (`-- --token=…`), and the line reached an OTLP span, the
|
|
185
|
+
* GitHub job summary and a check-run posted over the API verbatim (item
|
|
186
|
+
* 1057). Local history (`vx last`) keeps the whole line, on this machine.
|
|
187
|
+
*/
|
|
188
|
+
export function invocationCommand(argv: readonly string[]): string {
|
|
189
|
+
const sep = argv.indexOf('--')
|
|
190
|
+
if (sep === -1) return argv.join(' ')
|
|
191
|
+
const rest = argv.length - sep - 1
|
|
192
|
+
return [...argv.slice(0, sep), '--', `<${rest} argument${rest === 1 ? '' : 's'}>`].join(' ')
|
|
193
|
+
}
|
|
194
|
+
|
|
176
195
|
export async function run(options: RunOptions): Promise<RunSummary> {
|
|
177
196
|
// Color decision: a custom logger (tests, embedders) handles its
|
|
178
197
|
// own formatting and asserts on plain strings, so we suppress
|
|
@@ -465,7 +484,11 @@ async function runOnBus(
|
|
|
465
484
|
// SIGKILL after the grace, and a run that returned first left that child
|
|
466
485
|
// running past vx's exit (turborepo#14043's class).
|
|
467
486
|
let aborting: Promise<void> | undefined
|
|
487
|
+
// The servers that had ended when the stop landed: those crashed. One that
|
|
488
|
+
// ends after it is the stop's own kill, and was named a crash (item 1061).
|
|
489
|
+
let endedBeforeStop: ReadonlySet<ReturnType<typeof Bun.spawn>> | undefined
|
|
468
490
|
const onAbort = (): void => {
|
|
491
|
+
endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
|
|
469
492
|
aborting = terminateChildren(
|
|
470
493
|
() => [...liveChildren, ...persistentRegistry.values()],
|
|
471
494
|
forwardedSignal(stopRun.signal.reason),
|
|
@@ -566,7 +589,7 @@ async function runOnBus(
|
|
|
566
589
|
runContextRecord = {
|
|
567
590
|
runId,
|
|
568
591
|
vxVersion: VERSION,
|
|
569
|
-
command: options.command ?? process.argv.slice(1)
|
|
592
|
+
command: options.command ?? invocationCommand(process.argv.slice(1)),
|
|
570
593
|
requestedTasks: [...options.tasks],
|
|
571
594
|
cachePolicy: compactCachePolicy(policy),
|
|
572
595
|
concurrency,
|
|
@@ -707,13 +730,13 @@ async function runOnBus(
|
|
|
707
730
|
`[vx] --exclude-dependencies: ${excluded.unsaved} cached task(s) build on a skipped dependency; what they build is not saved`,
|
|
708
731
|
)
|
|
709
732
|
}
|
|
710
|
-
const
|
|
733
|
+
const taint = taintTracker(options.continueMode === 'always', excluded.seeds, nodes)
|
|
711
734
|
const dependedOn = new Set<string>()
|
|
712
735
|
for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
|
|
713
736
|
|
|
714
737
|
const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
|
|
715
738
|
const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
|
|
716
|
-
const
|
|
739
|
+
const tainted = taint.judge(node, upstream)
|
|
717
740
|
return {
|
|
718
741
|
node,
|
|
719
742
|
upstream,
|
|
@@ -737,7 +760,7 @@ async function runOnBus(
|
|
|
737
760
|
gitFilesCache,
|
|
738
761
|
hashCache,
|
|
739
762
|
...(probe !== undefined ? { preProbed: probe } : {}),
|
|
740
|
-
...(
|
|
763
|
+
...(tainted ? { taintedUpstream: true } : {}),
|
|
741
764
|
...(dependedOn.has(node.id) ? {} : { noDependants: true as const }),
|
|
742
765
|
fingerprintWatch,
|
|
743
766
|
...(sandboxArmer !== null ? { armSandbox: () => sandboxArmer.arm() } : {}),
|
|
@@ -778,6 +801,7 @@ async function runOnBus(
|
|
|
778
801
|
log.taskStart?.(node)
|
|
779
802
|
},
|
|
780
803
|
onFinish: (o) => {
|
|
804
|
+
taint.settled(o)
|
|
781
805
|
log.taskComplete(o.node, o)
|
|
782
806
|
narrowDemand(o.node.id)
|
|
783
807
|
},
|
|
@@ -801,14 +825,34 @@ async function runOnBus(
|
|
|
801
825
|
// An aborted run's children are already being torn down: nothing to hold.
|
|
802
826
|
const hold = options.holdPersistent === true && !stopRun.signal.aborted
|
|
803
827
|
const keepAlive = selectKeepAlive(persistentRegistry, nodes, foreground || hold)
|
|
804
|
-
const crashedPersistent =
|
|
828
|
+
const crashedPersistent = (
|
|
829
|
+
await shutdownPersistent(persistentRegistry, keepAlive.children)
|
|
830
|
+
).filter((c) => endedBeforeStop?.has(persistentRegistry.get(c.id)!) ?? true)
|
|
805
831
|
|
|
806
832
|
mark('run graph')
|
|
807
833
|
// Clear the status line for good before the summary prints.
|
|
808
834
|
log.runEnd?.()
|
|
809
835
|
|
|
836
|
+
// A server that died on its own before the stop failed, whatever its
|
|
837
|
+
// outcome said when it became ready: the footer counted it a success
|
|
838
|
+
// over the run's red exit (item 1071). A kept one that has already
|
|
839
|
+
// died is the same; the keep-alive wait below names it. Its code is its
|
|
840
|
+
// own, or the shell's report of the signal that took it.
|
|
841
|
+
const failServer = (id: string, code: number | string): void => {
|
|
842
|
+
const o = outcomes.get(id)
|
|
843
|
+
if (o === undefined) return
|
|
844
|
+
const exitCode = typeof code === 'number' ? code : signalExitCode(code)
|
|
845
|
+
outcomes.set(id, { ...o, status: 'failed', exitCode })
|
|
846
|
+
}
|
|
847
|
+
for (const c of crashedPersistent) failServer(c.id, c.code)
|
|
848
|
+
keepAlive.nodes.forEach((n, i) => {
|
|
849
|
+
const child = keepAlive.children[i]!
|
|
850
|
+
if (!hasEnded(child) || child.exitCode === 0) return
|
|
851
|
+
if (endedBeforeStop !== undefined && !endedBeforeStop.has(child)) return
|
|
852
|
+
failServer(n.id, child.exitCode ?? child.signalCode ?? 'unknown')
|
|
853
|
+
})
|
|
810
854
|
const list = [...outcomes.values()]
|
|
811
|
-
const ok = list.every((o) => isPassStatus(o.status))
|
|
855
|
+
const ok = list.every((o) => isPassStatus(o.status))
|
|
812
856
|
|
|
813
857
|
// The summary + artifact writers + recordRun pass all exclude group
|
|
814
858
|
// tasks via the shared tallyOutcomes helper. We pass the full
|
|
@@ -827,10 +871,15 @@ async function runOnBus(
|
|
|
827
871
|
// A task killed by a shutdown signal is in no bucket above, yet it makes
|
|
828
872
|
// `ok` false — name it, or the red exit is undiagnosable.
|
|
829
873
|
for (const line of formatAbortedSection(list)) log.status(line)
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
|
|
833
|
-
|
|
874
|
+
// And a dependency-only server that died before the end of the graph
|
|
875
|
+
// stopped it: the footer counts it failed, this says why.
|
|
876
|
+
// A signal death is named as its exit code, as the keep-alive wait
|
|
877
|
+
// names it: `code SIGTERM` read beside `code 143` for one event (item
|
|
878
|
+
// 1102).
|
|
879
|
+
for (const c of crashedPersistent) {
|
|
880
|
+
const code = typeof c.code === 'number' ? c.code : signalExitCode(c.code)
|
|
881
|
+
log.status(`vx: ${c.id} exited with code ${code} before the run stopped it`)
|
|
882
|
+
}
|
|
834
883
|
// The footer's "N skipped" names no task; this names each under the
|
|
835
884
|
// failure that blocked it.
|
|
836
885
|
for (const line of formatSkippedSection(list)) log.status(line)
|
|
@@ -971,11 +1020,25 @@ async function runOnBus(
|
|
|
971
1020
|
// directories are old enough for the snapshot's racy window (see
|
|
972
1021
|
// miss-save.ts). A few at a time: each is an lstat + readdir per
|
|
973
1022
|
// prefix; the rows land together at close, in one transaction.
|
|
1023
|
+
// Each walk is held to its entry's rows (item 1087), read here in one
|
|
1024
|
+
// batch: read per snapshot, each read also flushed the snapshots before
|
|
1025
|
+
// it, one transaction apiece (+110 ms at 1,000).
|
|
1026
|
+
const snapshotRows = new Map<string, ReadonlyArray<{ path: string }>>()
|
|
1027
|
+
for (let i = 0; i < outputDirSnapshots.length; i += 900) {
|
|
1028
|
+
const batch = cache.loadOutputFilesBatch(
|
|
1029
|
+
outputDirSnapshots.slice(i, i + 900).map((s) => s.hash),
|
|
1030
|
+
)
|
|
1031
|
+
for (const [hash, rows] of batch) snapshotRows.set(hash, rows)
|
|
1032
|
+
}
|
|
974
1033
|
for (let i = 0; i < outputDirSnapshots.length; i += 32) {
|
|
975
1034
|
await Promise.all(
|
|
976
1035
|
outputDirSnapshots
|
|
977
1036
|
.slice(i, i + 32)
|
|
978
|
-
.map((s) =>
|
|
1037
|
+
.map((s) =>
|
|
1038
|
+
cache.recordOutputDirs?.(s.hash, s.projectDir, s.prefixes, (files) =>
|
|
1039
|
+
s.holds(files, snapshotRows.get(s.hash) ?? []),
|
|
1040
|
+
),
|
|
1041
|
+
),
|
|
979
1042
|
)
|
|
980
1043
|
}
|
|
981
1044
|
mark('output dir snapshots')
|
|
@@ -96,7 +96,7 @@ export function sandboxRunUnion(nodes: Iterable<TaskNode>): SandboxRunUnion | nu
|
|
|
96
96
|
* The domain union is computed here from every sandboxed node, because
|
|
97
97
|
* SRT runs ONE filtering proxy per run and checks every request against
|
|
98
98
|
* the allowlist given to `initialize()` — never the per-call one
|
|
99
|
-
* (`sandbox-manager.js
|
|
99
|
+
* (`sandbox-manager.js`, 0.0.75 and 0.0.76). A task that declares no domains still
|
|
100
100
|
* reaches nothing: its profile is not given the proxy's port at all.
|
|
101
101
|
* The unix-socket allowance is per run the same way: SRT's Linux seccomp
|
|
102
102
|
* filter on `socket(AF_UNIX)` is all-or-nothing and read at
|
|
@@ -183,11 +183,14 @@ export async function sandboxRequestFor(
|
|
|
183
183
|
// Pre-create what the task said it will write — after the grants are
|
|
184
184
|
// resolved, which refuses one that leaves the project through a link
|
|
185
185
|
// before anything is created along it (item 1003).
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
186
|
+
const walls = [...nested, path.join(workspaceRoot, '.git'), path.join(workspaceRoot, '.vx')].map(
|
|
187
|
+
toRealPath,
|
|
188
|
+
)
|
|
189
|
+
const config = wallOff(
|
|
190
|
+
resolveSandboxConfig(sandbox, node.projectDir, walls),
|
|
191
|
+
workspaceRoot,
|
|
192
|
+
walls,
|
|
193
|
+
)
|
|
191
194
|
const placeholders = await prepareOutputsForBind(node.projectDir, sandbox.allow?.write ?? [])
|
|
192
195
|
const request: NonNullable<ExecuteRequest['sandbox']> = {
|
|
193
196
|
// Only what the task declared, plus node_modules. Write paths are
|
|
@@ -197,7 +200,13 @@ export async function sandboxRequestFor(
|
|
|
197
200
|
// Enforcement anchors at the WORKSPACE ROOT: a task may not leave
|
|
198
201
|
// its project, so every sibling and every root file is denied.
|
|
199
202
|
// Reporting is a different question — see `reportWithin` below.
|
|
200
|
-
|
|
203
|
+
// On macOS the walls are denied too: seatbelt does not mount, so the
|
|
204
|
+
// punch cannot apply, and SRT emits a deny strictly inside a literal
|
|
205
|
+
// read grant AFTER the grant, where it wins (`lateReadDenyFilters`,
|
|
206
|
+
// 0.0.76). A grant naming a wall is not strictly outside it and wins.
|
|
207
|
+
// Without it a root task's `read: ['.']` read its nested projects under
|
|
208
|
+
// seatbelt (B-4).
|
|
209
|
+
baseDenyRead: process.platform === 'darwin' ? [workspaceRoot, ...walls] : [workspaceRoot],
|
|
201
210
|
// …but only denials INSIDE the project are worth reporting. A task
|
|
202
211
|
// bumping into the wall is the sandbox working, not a finding: the
|
|
203
212
|
// walk `bun build --compile` makes from `/` down to its cwd lists
|
|
@@ -422,14 +431,14 @@ async function prepareOutputsForBind(
|
|
|
422
431
|
* punched (`punchWalls`); a write grant whose bind would hold one — a file
|
|
423
432
|
* grant binds its directory on Linux, so `write: ['out.txt']` in a root
|
|
424
433
|
* project bound the whole workspace writable, `.git` included — is
|
|
425
|
-
* refused, as there is no writable bind that leaves a wall out.
|
|
434
|
+
* refused, as there is no writable bind that leaves a wall out. A glob's
|
|
435
|
+
* hits already stop at the walls (`resolveSandboxConfig` takes them).
|
|
426
436
|
*/
|
|
427
437
|
function wallOff(
|
|
428
438
|
config: ResolvedSandboxConfig,
|
|
429
439
|
workspaceRoot: string,
|
|
430
|
-
|
|
440
|
+
walls: readonly string[],
|
|
431
441
|
): ResolvedSandboxConfig {
|
|
432
|
-
const walls = raw.map(toRealPath)
|
|
433
442
|
const home = toRealPath(workspaceRoot)
|
|
434
443
|
for (const bind of bindableWrites(config.allowWrite)) {
|
|
435
444
|
// A bind outside the workspace is the user's own path (`/tmp/x`,
|