@vzn/vx 0.0.591 → 0.0.594
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/cache/archive.ts +16 -5
- package/src/cache/cache.ts +41 -11
- package/src/cache/git-inputs.ts +14 -9
- package/src/cache/inputs.ts +47 -17
- package/src/cache/tar-stream.ts +3 -1
- package/src/cli/cache.ts +3 -5
- package/src/cli/foreign-flags.ts +33 -4
- package/src/cli/help.ts +22 -5
- package/src/cli/info.ts +4 -6
- package/src/cli/init.ts +3 -3
- package/src/cli/last.ts +49 -20
- package/src/cli/lock.ts +3 -3
- package/src/cli/run.ts +65 -37
- package/src/cli/select.ts +14 -2
- package/src/cli/show.ts +5 -6
- package/src/cli/upgrade.ts +2 -3
- package/src/cli/watch-filter.ts +7 -6
- package/src/cli/watch-fs.ts +17 -1
- package/src/cli/watch.ts +22 -4
- package/src/cli/why.ts +4 -13
- package/src/cli/workspace-config.ts +1 -1
- package/src/exec/env.ts +3 -5
- package/src/exec/runner.ts +28 -7
- package/src/exec/sandbox-deny-scan.ts +2 -2
- package/src/exec/sandbox-runtime.ts +69 -19
- package/src/exec/sandbox-violations.ts +23 -9
- package/src/graph/scheduler.ts +13 -3
- package/src/graph/task-graph.ts +3 -2
- package/src/index.ts +3 -0
- package/src/orchestrator/affected-tasks.ts +34 -10
- package/src/orchestrator/doctor.ts +2 -1
- package/src/orchestrator/execute-task.ts +14 -3
- package/src/orchestrator/hit-restore.ts +3 -2
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/lockfile-claim.ts +2 -3
- 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/plugin-host.ts +16 -0
- package/src/orchestrator/plugin.ts +9 -13
- 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 +42 -8
- package/src/orchestrator/sandbox-request.ts +6 -10
- package/src/workspace/affected.ts +17 -11
- package/src/workspace/config-eval.ts +4 -0
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +2 -7
- package/src/workspace/filter.ts +5 -4
- package/src/workspace/migrate-scripts.ts +22 -5
- package/src/workspace/project-loader.ts +17 -2
- package/src/workspace/workspace.ts +40 -4
package/src/exec/runner.ts
CHANGED
|
@@ -483,7 +483,7 @@ export interface PersistentSpawn {
|
|
|
483
483
|
* Rejects with the spawn error if the child fails to start.
|
|
484
484
|
*/
|
|
485
485
|
ready: Promise<void>
|
|
486
|
-
/** ms
|
|
486
|
+
/** ms from spawn to ready, to the readiness timeout giving up, or to now. */
|
|
487
487
|
readyMs: () => number
|
|
488
488
|
}
|
|
489
489
|
|
|
@@ -522,6 +522,9 @@ export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs' | 'cap
|
|
|
522
522
|
export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
523
523
|
const start = Date.now()
|
|
524
524
|
let readyAt: number | undefined
|
|
525
|
+
// When the readiness wait gave up: the wait's length, not the kill grace
|
|
526
|
+
// a TERM-trapping server runs out after it.
|
|
527
|
+
let gaveUpAt: number | undefined
|
|
525
528
|
|
|
526
529
|
// Pattern compiled once; thrown errors surface synchronously so the
|
|
527
530
|
// caller can wrap with a user-facing message.
|
|
@@ -679,6 +682,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
679
682
|
if (readyRe && opts.timeoutMs !== undefined) {
|
|
680
683
|
readyTimer = setTimeout(() => {
|
|
681
684
|
if (readyAt === undefined) {
|
|
685
|
+
gaveUpAt = Date.now()
|
|
682
686
|
rejectReady(
|
|
683
687
|
new PersistentReadyError(
|
|
684
688
|
`persistent task not ready within ${opts.timeoutMs}ms — ` +
|
|
@@ -730,7 +734,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
730
734
|
return {
|
|
731
735
|
child,
|
|
732
736
|
ready,
|
|
733
|
-
readyMs: () => (readyAt ?? Date.now()) - start,
|
|
737
|
+
readyMs: () => (readyAt ?? gaveUpAt ?? Date.now()) - start,
|
|
734
738
|
}
|
|
735
739
|
}
|
|
736
740
|
|
|
@@ -807,7 +811,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
807
811
|
stderr,
|
|
808
812
|
...(proc.signalCode ? { signal: proc.signalCode } : {}),
|
|
809
813
|
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
810
|
-
...resourceUsageToCpuRss(proc.resourceUsage(),
|
|
814
|
+
...resourceUsageToCpuRss(proc.resourceUsage(), rssFloorFor),
|
|
811
815
|
}
|
|
812
816
|
}
|
|
813
817
|
|
|
@@ -832,7 +836,7 @@ export function ownRssHighWater(): number {
|
|
|
832
836
|
if (process.platform === 'linux') {
|
|
833
837
|
try {
|
|
834
838
|
const m = /VmHWM:\s+(\d+) kB/.exec(readFileSync('/proc/self/status', 'utf8'))
|
|
835
|
-
if (m !== null) return Number(m[1]) * 1024
|
|
839
|
+
if (m !== null) return (highWaterSeen = Number(m[1]) * 1024)
|
|
836
840
|
} catch {
|
|
837
841
|
// /proc unreadable: fall through to the current RSS.
|
|
838
842
|
}
|
|
@@ -840,6 +844,22 @@ export function ownRssHighWater(): number {
|
|
|
840
844
|
return process.memoryUsage.rss()
|
|
841
845
|
}
|
|
842
846
|
|
|
847
|
+
/** The last `VmHWM` read, in bytes; 0 until one is. */
|
|
848
|
+
let highWaterSeen = 0
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* The floor for a child's `peak`, read only when the last `VmHWM` cannot
|
|
852
|
+
* decide it: the mark never falls, so a peak within the slack of an older
|
|
853
|
+
* reading is within the slack of the current one, and goes unreported
|
|
854
|
+
* either way. A light task reads vx's own mark as its peak, so after the
|
|
855
|
+
* first such task the rest skip the read, which cost ~35 µs of the
|
|
856
|
+
* scheduler's thread per task (500 cold tasks, 2026-10-07). A current-RSS
|
|
857
|
+
* fallback is not monotonic and is never reused.
|
|
858
|
+
*/
|
|
859
|
+
function rssFloorFor(peak: number): number {
|
|
860
|
+
return peak <= highWaterSeen + RSS_FLOOR_SLACK_BYTES ? highWaterSeen : ownRssHighWater()
|
|
861
|
+
}
|
|
862
|
+
|
|
843
863
|
/**
|
|
844
864
|
* What a task's captured output keeps: the first `CAPTURE_HEAD_CHARS` and
|
|
845
865
|
* the last `CAPTURE_TAIL_CHARS` characters, the dropped middle counted and
|
|
@@ -1014,8 +1034,8 @@ export function peakRssBytes(maxRSS: number): number {
|
|
|
1014
1034
|
*/
|
|
1015
1035
|
export function resourceUsageToCpuRss(
|
|
1016
1036
|
usage: ReturnType<ReturnType<typeof Bun.spawn>['resourceUsage']>,
|
|
1017
|
-
/** The parent's own high-water mark (`ownRssHighWater`); a peak at or under it is inherited, not the child's, and is not reported. */
|
|
1018
|
-
floorBytes = 0,
|
|
1037
|
+
/** The parent's own high-water mark (`ownRssHighWater`), or a function of the peak that answers it; a peak at or under it is inherited, not the child's, and is not reported. */
|
|
1038
|
+
floorBytes: number | ((peak: number) => number) = 0,
|
|
1019
1039
|
): { cpuMs?: number; peakRssBytes?: number } {
|
|
1020
1040
|
if (!usage) return {}
|
|
1021
1041
|
// cpuTime.total is microseconds as a bigint → ms.
|
|
@@ -1028,5 +1048,6 @@ export function resourceUsageToCpuRss(
|
|
|
1028
1048
|
// parent's (see `ownRssHighWater`, `RSS_FLOOR_SLACK_BYTES`): the child's
|
|
1029
1049
|
// peak is unknown, bounded by it.
|
|
1030
1050
|
const peak = peakRssBytes(usage.maxRSS)
|
|
1031
|
-
|
|
1051
|
+
const floor = typeof floorBytes === 'number' ? floorBytes : floorBytes(peak)
|
|
1052
|
+
return peak > floor + RSS_FLOOR_SLACK_BYTES ? { cpuMs, peakRssBytes: peak } : { cpuMs }
|
|
1032
1053
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { lstatSync, readdirSync } from 'node:fs'
|
|
17
17
|
import { homedir } from 'node:os'
|
|
18
18
|
import path from 'node:path'
|
|
19
|
-
import { UserError } from '../util/index.js'
|
|
19
|
+
import { relPosix, UserError } from '../util/index.js'
|
|
20
20
|
import { atOrUnder, MOUNT_WILDCARDS } from './sandbox-paths.js'
|
|
21
21
|
|
|
22
22
|
/** SRT's `rg --max-depth` for the scan (`DEFAULT_MANDATORY_DENY_SEARCH_DEPTH`). */
|
|
@@ -126,7 +126,7 @@ export function scopedMandatoryDenies(
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
for (const root of roots) {
|
|
129
|
-
const rel =
|
|
129
|
+
const rel = relPosix(cwd, root)
|
|
130
130
|
const depth = rel === '' ? 0 : rel.split('/').length
|
|
131
131
|
if (depth > SEARCH_DEPTH) continue
|
|
132
132
|
let dir: boolean
|
|
@@ -108,9 +108,13 @@ import {
|
|
|
108
108
|
|
|
109
109
|
type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
|
|
110
110
|
let srtPromise: Promise<SrtModule> | undefined
|
|
111
|
+
/** Set once loaded, for `releaseBridges`, which a sync exit handler calls. */
|
|
112
|
+
let srtLoaded: SrtModule | undefined
|
|
111
113
|
|
|
112
114
|
async function loadSrt(): Promise<SrtModule> {
|
|
113
|
-
if (!srtPromise)
|
|
115
|
+
if (!srtPromise) {
|
|
116
|
+
srtPromise = import('@anthropic-ai/sandbox-runtime').then((m) => (srtLoaded = m))
|
|
117
|
+
}
|
|
114
118
|
return srtPromise
|
|
115
119
|
}
|
|
116
120
|
|
|
@@ -1151,6 +1155,23 @@ export async function wrapSandboxedCommand(
|
|
|
1151
1155
|
const tmp = taskTmpdir(tag)
|
|
1152
1156
|
mkdirSync(tmp, { mode: 0o700 })
|
|
1153
1157
|
trackTaskTmpdir(tmp)
|
|
1158
|
+
try {
|
|
1159
|
+
return await wrapIn(args, SandboxManager, userCommand, tag, tmp)
|
|
1160
|
+
} catch (err) {
|
|
1161
|
+
// Nothing spawned, so nothing releases it on exit: a `vx watch` kept
|
|
1162
|
+
// one task directory per refused wrap (a held port) until it quit.
|
|
1163
|
+
releaseBridges(tag)
|
|
1164
|
+
throw err
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function wrapIn(
|
|
1169
|
+
args: Parameters<typeof wrapSandboxedCommand>[0],
|
|
1170
|
+
SandboxManager: SrtModule['SandboxManager'],
|
|
1171
|
+
userCommand: string,
|
|
1172
|
+
tag: string,
|
|
1173
|
+
tmp: string,
|
|
1174
|
+
): ReturnType<typeof wrapSandboxedCommand> {
|
|
1154
1175
|
// After the tag: SRT keys violations by the command's first 100 chars.
|
|
1155
1176
|
const inTmp = `export TMPDIR=${shellQuote(tmp)}; ${javaToolOptionsFix(
|
|
1156
1177
|
process.env['JAVA_TOOL_OPTIONS'],
|
|
@@ -1188,6 +1209,16 @@ export async function wrapSandboxedCommand(
|
|
|
1188
1209
|
// user command, so it goes INTO the sandboxed command; the host side is
|
|
1189
1210
|
// spawned here and released when the task's process ends.
|
|
1190
1211
|
const ports = process.platform === 'linux' ? bridgedPorts(args.config) : []
|
|
1212
|
+
// Before the wrap, which holds the runtime's stub cleanup until `afterCommand`.
|
|
1213
|
+
const held = portsHeld(ports)
|
|
1214
|
+
if (held.length > 0) {
|
|
1215
|
+
throw new UserError(
|
|
1216
|
+
`sandbox: localBinding port${held.length === 1 ? '' : 's'} ${held.join(', ')} ` +
|
|
1217
|
+
`${held.length === 1 ? 'is' : 'are'} already in use on this machine, so the task's own ` +
|
|
1218
|
+
`cannot be exposed there and a client would reach the other listener; stop what ` +
|
|
1219
|
+
`holds ${held.length === 1 ? 'it' : 'them'} or list another port`,
|
|
1220
|
+
)
|
|
1221
|
+
}
|
|
1191
1222
|
const grouped =
|
|
1192
1223
|
process.platform === 'linux'
|
|
1193
1224
|
? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
|
|
@@ -1240,15 +1271,6 @@ export async function wrapSandboxedCommand(
|
|
|
1240
1271
|
}
|
|
1241
1272
|
if (process.platform === 'linux' && !hostHasIpv6())
|
|
1242
1273
|
wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
|
|
1243
|
-
const held = portsHeld(ports)
|
|
1244
|
-
if (held.length > 0) {
|
|
1245
|
-
throw new UserError(
|
|
1246
|
-
`sandbox: localBinding port${held.length === 1 ? '' : 's'} ${held.join(', ')} ` +
|
|
1247
|
-
`${held.length === 1 ? 'is' : 'are'} already in use on this machine, so the task's own ` +
|
|
1248
|
-
`cannot be exposed there and a client would reach the other listener; stop what ` +
|
|
1249
|
-
`holds ${held.length === 1 ? 'it' : 'them'} or list another port`,
|
|
1250
|
-
)
|
|
1251
|
-
}
|
|
1252
1274
|
if (args.server === true) liveServers.add(tag)
|
|
1253
1275
|
if (ports.length > 0) {
|
|
1254
1276
|
spawnHostBridges(ports, tag)
|
|
@@ -1409,9 +1431,11 @@ function literalReadPaths(
|
|
|
1409
1431
|
* it the pattern, and SRT compiles any spelling holding `[` as a regex in
|
|
1410
1432
|
* which a backslash is a literal one, so `pages/\[id\].tsx` matched no
|
|
1411
1433
|
* file and the route could not be granted. `[[]` is a class of one `[`; a
|
|
1412
|
-
* lone `]` is plain text (B-65).
|
|
1434
|
+
* lone `]` is plain text (B-65). A deny path is a real directory, never a
|
|
1435
|
+
* pattern: a nested project's wall under `[legacy]/` compiled as a class,
|
|
1436
|
+
* matched nothing, and the root task read it.
|
|
1413
1437
|
*/
|
|
1414
|
-
function seatbeltBrackets(
|
|
1438
|
+
export function seatbeltBrackets(
|
|
1415
1439
|
config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
|
|
1416
1440
|
): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
|
|
1417
1441
|
const fs = config?.filesystem
|
|
@@ -1422,6 +1446,7 @@ function seatbeltBrackets(
|
|
|
1422
1446
|
...config,
|
|
1423
1447
|
filesystem: {
|
|
1424
1448
|
...fs,
|
|
1449
|
+
denyRead: fs.denyRead.map((p) => p.replaceAll('[', '[[]')),
|
|
1425
1450
|
allowWrite: literal(fs.allowWrite),
|
|
1426
1451
|
...(fs.allowRead !== undefined ? { allowRead: literal(fs.allowRead) } : {}),
|
|
1427
1452
|
},
|
|
@@ -1620,8 +1645,17 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
|
1620
1645
|
export function releaseBridges(tag: string): void {
|
|
1621
1646
|
const tmp = taskTmpdir(tag)
|
|
1622
1647
|
if (liveTaskTmpdirs.delete(tmp)) rmSync(tmp, { recursive: true, force: true })
|
|
1623
|
-
if (liveServers.delete(tag)
|
|
1624
|
-
|
|
1648
|
+
if (liveServers.delete(tag)) {
|
|
1649
|
+
// A server's wrap counts as a live sandbox in SRT until this, and SRT
|
|
1650
|
+
// removes bwrap's host stubs (`.bashrc`, `.vscode`, … under a write
|
|
1651
|
+
// grant) only at a count of 0: one stopped server kept every later
|
|
1652
|
+
// task's stubs in the workspace until the reset.
|
|
1653
|
+
try {
|
|
1654
|
+
srtLoaded!.SandboxManager.cleanupAfterCommand()
|
|
1655
|
+
} catch {
|
|
1656
|
+
// best-effort, as a one-shot task's
|
|
1657
|
+
}
|
|
1658
|
+
if (liveServers.size === 0 && resetDeferred) void resetSandbox().catch(() => {})
|
|
1625
1659
|
}
|
|
1626
1660
|
const bridges = hostBridges.get(tag)
|
|
1627
1661
|
if (bridges === undefined) return
|
|
@@ -1775,6 +1809,7 @@ async function runSandboxedOnce(
|
|
|
1775
1809
|
if (args.signal?.aborted === true) {
|
|
1776
1810
|
releaseBridges(tag)
|
|
1777
1811
|
takeRecords()
|
|
1812
|
+
afterCommand(SandboxManager)
|
|
1778
1813
|
const signal = stopSignal(args.signal.reason)
|
|
1779
1814
|
return {
|
|
1780
1815
|
exitCode: signalExitCode(signal),
|
|
@@ -1819,10 +1854,15 @@ async function runSandboxedOnce(
|
|
|
1819
1854
|
)
|
|
1820
1855
|
if (forwardsSignals) signalThrough(proc, proc.stdio[3] as number)
|
|
1821
1856
|
} catch (err) {
|
|
1857
|
+
if (straceLog) {
|
|
1858
|
+
rmSync(straceLog, { force: true })
|
|
1859
|
+
liveTempFiles.delete(straceLog)
|
|
1860
|
+
}
|
|
1822
1861
|
const stderr = spawnFailureText(err, args.cwd, 'sandboxed task')
|
|
1823
1862
|
args.onStderr?.(stderr)
|
|
1824
1863
|
releaseBridges(tag)
|
|
1825
1864
|
takeRecords()
|
|
1865
|
+
afterCommand(SandboxManager)
|
|
1826
1866
|
return {
|
|
1827
1867
|
exitCode: 127,
|
|
1828
1868
|
durationMs: Date.now() - start,
|
|
@@ -2012,11 +2052,7 @@ async function runSandboxedOnce(
|
|
|
2012
2052
|
if (hidden.length > 0) violations.push(hiddenReadsHint(hidden, args.reportWithin))
|
|
2013
2053
|
}
|
|
2014
2054
|
|
|
2015
|
-
|
|
2016
|
-
SandboxManager.cleanupAfterCommand()
|
|
2017
|
-
} catch {
|
|
2018
|
-
// ignore; bwrap mount-point cleanup is best-effort
|
|
2019
|
-
}
|
|
2055
|
+
afterCommand(SandboxManager)
|
|
2020
2056
|
|
|
2021
2057
|
return {
|
|
2022
2058
|
exitCode,
|
|
@@ -2042,6 +2078,20 @@ async function runSandboxedOnce(
|
|
|
2042
2078
|
}
|
|
2043
2079
|
}
|
|
2044
2080
|
|
|
2081
|
+
/**
|
|
2082
|
+
* The runtime counts every wrap as a live sandbox until this, and removes
|
|
2083
|
+
* the empty files bwrap made on the host as mount points (`.bashrc`,
|
|
2084
|
+
* `.vscode`, … under a write grant) only at a count of 0: a wrap with no
|
|
2085
|
+
* call here kept every later task's stubs on the host until the reset.
|
|
2086
|
+
*/
|
|
2087
|
+
function afterCommand(SandboxManager: SrtModule['SandboxManager']): void {
|
|
2088
|
+
try {
|
|
2089
|
+
SandboxManager.cleanupAfterCommand()
|
|
2090
|
+
} catch {
|
|
2091
|
+
// ignore; bwrap mount-point cleanup is best-effort
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2045
2095
|
/** The hint for writes refused outside the project, a few paths named. */
|
|
2046
2096
|
function outsideWritesHint(
|
|
2047
2097
|
paths: readonly string[],
|
|
@@ -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/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
|
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
|
|
@@ -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
|
}
|
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
span,
|
|
52
52
|
UserError,
|
|
53
53
|
} from '../util/index.js'
|
|
54
|
-
import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
|
|
54
|
+
import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS, terminateChildren } from './signals.js'
|
|
55
55
|
import { executorLabel, nameExecutorFailure } from './plugin-host.js'
|
|
56
56
|
import {
|
|
57
57
|
mayWriteFingerprint,
|
|
@@ -133,6 +133,8 @@ export interface ExecuteArgs {
|
|
|
133
133
|
* terms (item 962).
|
|
134
134
|
*/
|
|
135
135
|
stopSignal?: AbortSignal
|
|
136
|
+
/** `continueMode: 'never'` stopped dispatch: no further attempt starts. */
|
|
137
|
+
failFast?: AbortSignal
|
|
136
138
|
/**
|
|
137
139
|
* Registry the orchestrator owns. For each persistent task we
|
|
138
140
|
* spawn, we stash the subprocess handle here so the orchestrator
|
|
@@ -464,6 +466,11 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
464
466
|
try {
|
|
465
467
|
await spawn.ready
|
|
466
468
|
} catch (err) {
|
|
469
|
+
// A shell that exited before ready can leave its group running
|
|
470
|
+
// (`server & …; exit`): never registered, so no teardown reached it,
|
|
471
|
+
// and it held its port against the next `vx watch` cycle's server.
|
|
472
|
+
if (err instanceof PersistentReadyError && err.reason === 'exited')
|
|
473
|
+
await terminateChildren(() => [spawn.child])
|
|
467
474
|
// A server the run's stop killed while it started is aborted, as any
|
|
468
475
|
// task the stop kills (item 962): it read `failed (never ready:
|
|
469
476
|
// exited, exit 130)` with a recap after every Ctrl-C (C-62). The stop
|
|
@@ -512,6 +519,10 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
512
519
|
// One the readiness timeout is killing reports the signal's, as an
|
|
513
520
|
// ordinary timeout does (X-24).
|
|
514
521
|
const ready = err instanceof PersistentReadyError ? err : undefined
|
|
522
|
+
// The timer's SIGKILL is a grace away and the shell may die on the
|
|
523
|
+
// TERM first: a server that ignores it held its port past run() into
|
|
524
|
+
// the next `vx watch` cycle. Return once the group is gone.
|
|
525
|
+
if (ready?.reason === 'timeout') await terminateChildren(() => [spawn.child])
|
|
515
526
|
return {
|
|
516
527
|
node,
|
|
517
528
|
status: 'failed',
|
|
@@ -1058,7 +1069,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1058
1069
|
// no edge to the producer fails naming only the file (A-48).
|
|
1059
1070
|
if (code !== 0) {
|
|
1060
1071
|
for (const c of (args.gitFilesCache?.trackedCleansMissing(node.id) ?? []).slice(0, 3)) {
|
|
1061
|
-
const rel =
|
|
1072
|
+
const rel = relPosix(args.workspaceRoot, c.path)
|
|
1062
1073
|
log.taskStderr(
|
|
1063
1074
|
node,
|
|
1064
1075
|
`\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 +1164,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1153
1164
|
}
|
|
1154
1165
|
}
|
|
1155
1166
|
|
|
1156
|
-
if (effectiveExitCode === 0 || attempt >= maxAttempts) break
|
|
1167
|
+
if (effectiveExitCode === 0 || attempt >= maxAttempts || args.failFast?.aborted === true) break
|
|
1157
1168
|
failedAttempts.push({
|
|
1158
1169
|
endedAt: Date.now(),
|
|
1159
1170
|
exitCode: effectiveExitCode,
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import type { TaskOutcome } from '../graph/index.js'
|
|
23
23
|
import {
|
|
24
24
|
asTrees,
|
|
25
|
+
relPosix,
|
|
25
26
|
secretMask,
|
|
26
27
|
span,
|
|
27
28
|
splitNegations,
|
|
@@ -165,10 +166,10 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
165
166
|
const expectedRels = new Set(projExpected.map((e) => e.path))
|
|
166
167
|
const expectedWsRels = new Set(wsExpected.map((e) => e.path))
|
|
167
168
|
const actualRels = actualAbs
|
|
168
|
-
.map((p) =>
|
|
169
|
+
.map((p) => relPosix(node.projectDir, p))
|
|
169
170
|
.filter((rel) => expectedRels.has(rel) || !isAddition(rel))
|
|
170
171
|
const actualWsRels = actualWsAbs
|
|
171
|
-
.map((p) =>
|
|
172
|
+
.map((p) => relPosix(args.workspaceRoot, p))
|
|
172
173
|
.filter((rel) => expectedWsRels.has(rel) || !isAddition(rel))
|
|
173
174
|
const rowsPresent = (
|
|
174
175
|
actual: readonly string[],
|
|
@@ -15,7 +15,7 @@
|
|
|
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,
|
|
@@ -270,8 +270,7 @@ function digestFor(importers: ReadonlyMap<string, string>, importer: string): st
|
|
|
270
270
|
|
|
271
271
|
/** The lockfile's importer path for a project directory: `.` for the root, POSIX otherwise. */
|
|
272
272
|
function importerOf(workspaceRoot: string, projectDir: string): string {
|
|
273
|
-
|
|
274
|
-
return rel === '' ? '.' : rel.split(path.sep).join('/')
|
|
273
|
+
return relPosix(workspaceRoot, projectDir) || '.'
|
|
275
274
|
}
|
|
276
275
|
|
|
277
276
|
async function readMemo(
|
|
@@ -318,7 +318,7 @@ export function defaultLogger(
|
|
|
318
318
|
// for as long as vx holds it: `vx run dev --all` showed none of its
|
|
319
319
|
// servers' logs while it ran (C-56). So a ready server's output streams
|
|
320
320
|
// from then on, a line at a time under its id; the partial line each
|
|
321
|
-
// holds waits for its newline, a second runEnd, or `settle`.
|
|
321
|
+
// holds waits for its newline, a status line, a second runEnd, or `settle`.
|
|
322
322
|
const KEPT_LINE_CAP = 64 * 1024
|
|
323
323
|
const keptLines = new Map<string, { node: TaskNode; rest: string }>()
|
|
324
324
|
const streamKept = (node: TaskNode, chunk: string): void => {
|
|
@@ -450,6 +450,9 @@ export function defaultLogger(
|
|
|
450
450
|
writer.settle()
|
|
451
451
|
},
|
|
452
452
|
status(line) {
|
|
453
|
+
// A kept server's held partial line is older than this line: held
|
|
454
|
+
// past it, a server's last words printed below its own exit notice.
|
|
455
|
+
flushKept()
|
|
453
456
|
writer.write(`${line}\n`)
|
|
454
457
|
},
|
|
455
458
|
runStart(info) {
|