@vzn/vx 0.0.18 → 0.0.22
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/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { constants as osConstants } from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { execWord, exitSignal } from '../exec/index.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The frame line for a shell verdict the task's own output leaves a mystery.
|
|
8
|
+
* `sh -c 'exec <word> …'` exits 127 ("not found") and 126 ("cannot
|
|
9
|
+
* execute"), and its one line names the word and nothing about why: a bare
|
|
10
|
+
* word is a PATH lookup, and the PATH is vx's (item 257); a word with a
|
|
11
|
+
* slash is a file, and the file itself says why — missing, a directory, no
|
|
12
|
+
* execute bit, a `#!` interpreter that is not there (dash and bash 5 say
|
|
13
|
+
* "not found" and blame the file; bash 3.2 names it itself and exits 1),
|
|
14
|
+
* or no `#!` line at all (item 258). An exit above 128 is the shell's
|
|
15
|
+
* report of a death by signal, and the number alone says nothing about
|
|
16
|
+
* what sent it — the OOM killer, a crash in native code, an abort, a
|
|
17
|
+
* closed pipe (item 259).
|
|
18
|
+
*/
|
|
19
|
+
export function shellVerdict(args: {
|
|
20
|
+
code: number
|
|
21
|
+
command: string
|
|
22
|
+
cwd: string
|
|
23
|
+
bins: string[]
|
|
24
|
+
/** The signal that killed the child when the runner saw one (`RunResult.signal`). */
|
|
25
|
+
signal?: string | undefined
|
|
26
|
+
}): string | undefined {
|
|
27
|
+
if (args.code !== 127 && args.code !== 126) return signalVerdict(args.code, args.signal)
|
|
28
|
+
const word = execWord(args.command)
|
|
29
|
+
const what = word ?? 'a command in this task'
|
|
30
|
+
if (word !== undefined && word.includes('/')) {
|
|
31
|
+
const file = path.resolve(args.cwd, word)
|
|
32
|
+
return `[vx] exit ${args.code} is the shell's "${args.code === 127 ? 'not found' : 'cannot execute'}": ${word} ${fileVerdict(file)}`
|
|
33
|
+
}
|
|
34
|
+
if (args.code === 126) {
|
|
35
|
+
return `[vx] exit 126 is the shell's "found but cannot execute": ${what} is not executable or is a directory — chmod +x it`
|
|
36
|
+
}
|
|
37
|
+
return `[vx] exit 127 is the shell's "command not found": ${what} is not on this task's PATH — vx puts ${args.bins.join(' and ')} first and never a sibling project's bin; install it in this package or at the workspace root`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Why the shell could not run a file, read from the file. */
|
|
41
|
+
function fileVerdict(file: string): string {
|
|
42
|
+
if (!existsSync(file)) {
|
|
43
|
+
return `does not exist — looked for ${file}, relative to the task's working directory`
|
|
44
|
+
}
|
|
45
|
+
let head: string
|
|
46
|
+
try {
|
|
47
|
+
const st = statSync(file)
|
|
48
|
+
if (st.isDirectory()) return 'is a directory'
|
|
49
|
+
if ((st.mode & 0o111) === 0) return 'is not executable — chmod +x it'
|
|
50
|
+
head = readFileSync(file, 'latin1').slice(0, 256)
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return `could not be read (${(err as Error).message})`
|
|
53
|
+
}
|
|
54
|
+
if (!head.startsWith('#!')) {
|
|
55
|
+
return 'has no #! line, so it ran as a binary the loader refused — add a #! line or build it for this platform'
|
|
56
|
+
}
|
|
57
|
+
const nl = head.indexOf('\n')
|
|
58
|
+
const line = head.slice(2, nl === -1 ? head.length : nl)
|
|
59
|
+
if (line.endsWith('\r')) {
|
|
60
|
+
return `exists, and its #! line ends in CRLF, so the interpreter the shell looked for is "${line.trim()}\\r" — convert the file to LF line endings`
|
|
61
|
+
}
|
|
62
|
+
const interp = line.trim().split(/\s+/)[0] ?? ''
|
|
63
|
+
if (interp.startsWith('/') && !existsSync(interp)) {
|
|
64
|
+
return `exists, and its #! interpreter ${interp} does not — install it or fix the line`
|
|
65
|
+
}
|
|
66
|
+
return `exists and is executable, and the shell still could not run it — check its #! line (${line.trim()})`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The signal behind an exit above 128, from the runner's own report or the code alone. */
|
|
70
|
+
function signalVerdict(code: number, signal: string | undefined): string | undefined {
|
|
71
|
+
const signals = osConstants.signals as Partial<Record<string, number>>
|
|
72
|
+
let name: string | undefined
|
|
73
|
+
let num: number | undefined
|
|
74
|
+
if (signal !== undefined) {
|
|
75
|
+
name = signal
|
|
76
|
+
num = signals[signal]
|
|
77
|
+
} else {
|
|
78
|
+
name = exitSignal(code)
|
|
79
|
+
num = code - 128
|
|
80
|
+
}
|
|
81
|
+
if (name === undefined || num === undefined) return undefined
|
|
82
|
+
// A SIGINT/SIGTERM the runner saw is a shutdown or an outside stop, and
|
|
83
|
+
// the orchestrator reverts the task to aborted — that path has its own line.
|
|
84
|
+
if (signal !== undefined && (signal === 'SIGINT' || signal === 'SIGTERM')) return undefined
|
|
85
|
+
const why = SIGNAL_WHY[name] ?? 'the process was killed by that signal'
|
|
86
|
+
const how =
|
|
87
|
+
signal !== undefined
|
|
88
|
+
? `is how the shell reports a death by ${name} (${num})`
|
|
89
|
+
: `is 128 + ${num}, the shell's report of a death by ${name} in the last command (or that command exited ${code} itself)`
|
|
90
|
+
return `[vx] exit ${code} ${how}: ${why}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const SIGNAL_WHY: Record<string, string> = {
|
|
94
|
+
SIGKILL:
|
|
95
|
+
"nothing catches it — on Linux the kernel's OOM killer (dmesg, or the memory limit of the container's cgroup) or an explicit kill; vx's own timeout reports itself as a timeout",
|
|
96
|
+
SIGSEGV:
|
|
97
|
+
'the program crashed in native code (a native module, or the runtime itself) — re-run the command by hand to reproduce',
|
|
98
|
+
SIGBUS:
|
|
99
|
+
'the program crashed in native code (a native module, or the runtime itself) — re-run the command by hand to reproduce',
|
|
100
|
+
SIGILL:
|
|
101
|
+
'the program crashed in native code (a native module, or the runtime itself) — re-run the command by hand to reproduce',
|
|
102
|
+
SIGFPE:
|
|
103
|
+
'the program crashed in native code (a native module, or the runtime itself) — re-run the command by hand to reproduce',
|
|
104
|
+
SIGTRAP:
|
|
105
|
+
"the program hit a breakpoint or a trap instruction — a debugger, or a runtime's fatal check",
|
|
106
|
+
SIGABRT:
|
|
107
|
+
"the program aborted itself — an assertion, or an allocator failure (a JS runtime's heap limit reports here)",
|
|
108
|
+
SIGTERM:
|
|
109
|
+
"something outside vx asked the process to stop (a runner's cancellation, a container stop, a kill)",
|
|
110
|
+
SIGINT: 'something outside vx interrupted the process (a Ctrl-C reaching it, a kill -INT)',
|
|
111
|
+
SIGHUP: 'the process lost its controlling terminal or session (a hangup, a closed SSH session)',
|
|
112
|
+
SIGPIPE: 'it wrote to a pipe whose reader had gone — a reader in the command left early',
|
|
113
|
+
SIGXCPU: "a CPU-time limit (ulimit -t, or a sandbox's) was hit",
|
|
114
|
+
SIGXFSZ: 'a file-size limit (ulimit -f) was hit',
|
|
115
|
+
SIGSYS: 'a system call was refused — a seccomp filter, or the sandbox',
|
|
116
|
+
SIGQUIT: 'the process was sent a quit (Ctrl-\\, or a kill -QUIT); a core dump may be beside it',
|
|
117
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// A SIGINT/SIGTERM mid-run stops the run the way `RunOptions.signal` does
|
|
2
|
+
// — the scheduler dispatches nothing more, and the signal is forwarded to
|
|
3
|
+
// everything live, SIGKILLed after a bounded grace — then waits, bounded,
|
|
4
|
+
// for run() to leave its own end-of-run path (telemetry flush, each
|
|
5
|
+
// plugin's teardown, the cache close), and exits 128+signo (130/143). A
|
|
6
|
+
// second signal exits at once. Without the forward, a
|
|
7
|
+
// programmatic signal to the vx process alone (CI cancellation,
|
|
8
|
+
// `kill <pid>`) orphans every running child — terminal Ctrl-C only worked
|
|
9
|
+
// via process-group propagation. Without the escalation (added
|
|
10
|
+
// 2026-09-10) a child that traps TERM — a dev server mid-cleanup, a test
|
|
11
|
+
// runner that ignores it — outlived the run that owned it: vx exited
|
|
12
|
+
// 143 and left it running under init. Split from run.ts on 2026-09-10
|
|
13
|
+
// (pure motion); the registries it reads stay with `run()`, which hands
|
|
14
|
+
// them to the runner around every spawn.
|
|
15
|
+
|
|
16
|
+
import { signalExitCode } from '../exec/index.js'
|
|
17
|
+
import { killGraceMs, settleWithin } from '../util/index.js'
|
|
18
|
+
import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
|
|
19
|
+
import type { Logger } from './logger.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How long a SIGTERMed child gets before SIGKILL on the way out. The
|
|
23
|
+
* end-of-run persistent shutdown grants the same two seconds; both read
|
|
24
|
+
* `VX_KILL_GRACE_MS` (util/settle.ts) so the suites that prove the
|
|
25
|
+
* escalation do not wait the full grace.
|
|
26
|
+
*/
|
|
27
|
+
export const SIGNAL_SHUTDOWN_GRACE_MS = 2000
|
|
28
|
+
|
|
29
|
+
type Child = ReturnType<typeof Bun.spawn>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* SIGHUP too: a task runs in its own session (kill-tree.ts), so the
|
|
33
|
+
* terminal closing no longer reaches it — only vx hears the hang-up,
|
|
34
|
+
* and vx must pass it on or the tree outlives the window.
|
|
35
|
+
*/
|
|
36
|
+
export type StopSignal = 'SIGINT' | 'SIGTERM' | 'SIGHUP'
|
|
37
|
+
|
|
38
|
+
/** What a teardown sends a task's group before the SIGKILL. */
|
|
39
|
+
export type ForwardedSignal = 'SIGINT' | 'SIGTERM'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The signal a stop forwards to every task group: the one vx received.
|
|
43
|
+
* A task runs in its own session, so a terminal's Ctrl-C reaches vx
|
|
44
|
+
* alone; forwarding SIGTERM in its place skipped every SIGINT-only
|
|
45
|
+
* cleanup — a Node SIGINT listener, a shell's `trap … INT`
|
|
46
|
+
* (turborepo#444, #12652, #13097 and nx#23585 reproduced on vx,
|
|
47
|
+
* 2026-09-24). A hang-up forwards SIGTERM: to many servers SIGHUP means
|
|
48
|
+
* "reload", not "stop". Anything else — an embedder's abort reason that
|
|
49
|
+
* names no signal — is a SIGTERM.
|
|
50
|
+
*/
|
|
51
|
+
export function forwardedSignal(received: unknown): ForwardedSignal {
|
|
52
|
+
return received === 'SIGINT' ? 'SIGINT' : 'SIGTERM'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Send `signal` to every child `live()` returns, wait the grace for each one's
|
|
57
|
+
* process GROUP to go, then SIGKILL every group with a member left and
|
|
58
|
+
* whatever `live()` returns NOW — re-read, because the run loop may still
|
|
59
|
+
* be dispatching during the grace and a child spawned after the first
|
|
60
|
+
* sweep must not survive the second. The group, not the leader: a shell
|
|
61
|
+
* that dies at once on the SIGTERM ended the wait, and its backgrounded
|
|
62
|
+
* server was SIGKILLed mid-cleanup or, once the runner had dropped the
|
|
63
|
+
* shell, not at all. Resolves once the survivors are reaped. The one
|
|
64
|
+
* teardown behind the process-signal handler below, `RunOptions.signal`,
|
|
65
|
+
* and the foreground keep-alive.
|
|
66
|
+
*/
|
|
67
|
+
export async function terminateChildren(
|
|
68
|
+
live: () => Child[],
|
|
69
|
+
signal: ForwardedSignal = 'SIGTERM',
|
|
70
|
+
graceMs: number = killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS),
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
const children = live()
|
|
73
|
+
// On the group guard's list until the sweep below is done: a shell that
|
|
74
|
+
// dies on the signal lets its group go while what it forked runs out
|
|
75
|
+
// the grace (kill-tree.ts, `holdGroups`).
|
|
76
|
+
const letGo = holdGroups(children)
|
|
77
|
+
try {
|
|
78
|
+
for (const child of children) killTree(child, signal)
|
|
79
|
+
const left = await untilGroupsGone(children, graceMs)
|
|
80
|
+
const survivors = [...new Set([...left, ...live()])]
|
|
81
|
+
for (const child of survivors) killTree(child, 'SIGKILL')
|
|
82
|
+
await Promise.allSettled(survivors.map((c) => c.exited))
|
|
83
|
+
} finally {
|
|
84
|
+
letGo()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface SignalForwarding {
|
|
89
|
+
/** Detach the handlers — in `run()`'s finally, so repeated runs never stack listeners. */
|
|
90
|
+
remove(): void
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function forwardSignals(args: {
|
|
94
|
+
/** `RunOptions.handleSignals` — an embedder that owns the process's signals opts out. */
|
|
95
|
+
enabled: boolean
|
|
96
|
+
log: Logger
|
|
97
|
+
cache: { close(): void }
|
|
98
|
+
/**
|
|
99
|
+
* Stop the run as `RunOptions.signal` would: the scheduler stops
|
|
100
|
+
* dispatching and run()'s abort listener tears the children down.
|
|
101
|
+
*/
|
|
102
|
+
stop: (signal: StopSignal) => void
|
|
103
|
+
/**
|
|
104
|
+
* Settles when run() has left its `finally`. A signal waits for it
|
|
105
|
+
* before exiting: `process.exit` runs no `finally`, and plugin.md
|
|
106
|
+
* promises a plugin its teardown and a sink its flush at the end of
|
|
107
|
+
* every run — a Ctrl-C skipped both (item 849).
|
|
108
|
+
*/
|
|
109
|
+
done: Promise<void>
|
|
110
|
+
/** How long a signal waits for `done` before it exits anyway. */
|
|
111
|
+
boundMs: number
|
|
112
|
+
/** In-flight children; the runner adds and removes each around its spawn. */
|
|
113
|
+
liveChildren: ReadonlySet<Child>
|
|
114
|
+
/** Ready persistent tasks the orchestrator owns until the graph finishes. */
|
|
115
|
+
persistentRegistry: ReadonlyMap<string, Child>
|
|
116
|
+
}): SignalForwarding {
|
|
117
|
+
const everyChild = (): Child[] => [...args.liveChildren, ...args.persistentRegistry.values()]
|
|
118
|
+
const exit = (signal: StopSignal): never => {
|
|
119
|
+
for (const child of everyChild()) killTree(child, 'SIGKILL')
|
|
120
|
+
try {
|
|
121
|
+
args.cache.close()
|
|
122
|
+
} catch {
|
|
123
|
+
// double-close race with the normal path; we're exiting anyway
|
|
124
|
+
}
|
|
125
|
+
process.exit(signalExitCode(signal))
|
|
126
|
+
}
|
|
127
|
+
let stopping: StopSignal | undefined
|
|
128
|
+
const onSignal = (signal: StopSignal): void => {
|
|
129
|
+
// A second signal during the grace is the user saying "now": SIGKILL
|
|
130
|
+
// and go. The exit code stays the first signal's — that is the one
|
|
131
|
+
// that ended the run.
|
|
132
|
+
if (stopping !== undefined) exit(stopping)
|
|
133
|
+
stopping = signal
|
|
134
|
+
// Clear the live worker/status region BEFORE exiting so a TTY isn't
|
|
135
|
+
// left with a frozen region in the scrollback. runEnd is idempotent
|
|
136
|
+
// and a no-op for non-TTY loggers.
|
|
137
|
+
try {
|
|
138
|
+
args.log.runEnd?.()
|
|
139
|
+
} catch {
|
|
140
|
+
// teardown must not throw on the way out
|
|
141
|
+
}
|
|
142
|
+
args.stop(signal)
|
|
143
|
+
void settleWithin(args.done, args.boundMs)
|
|
144
|
+
// The run's output may still be in the pipe: `process.exit` drops
|
|
145
|
+
// what a reader has not taken (CLAUDE.md). An empty `write`'s
|
|
146
|
+
// callback fired early, and a CI-mode run lost 0.8 of 2 MiB (item
|
|
147
|
+
// 857); `end`'s waits for the pipe on Bun >= 1.4 (bin.ts). Bounded,
|
|
148
|
+
// for a reader that has gone and never takes it.
|
|
149
|
+
.then(() => settleWithin(new Promise<void>((r) => process.stdout.end(() => r())), 2_000))
|
|
150
|
+
.then(() => exit(signal))
|
|
151
|
+
}
|
|
152
|
+
const onSigint = (): void => onSignal('SIGINT')
|
|
153
|
+
const onSigterm = (): void => onSignal('SIGTERM')
|
|
154
|
+
const onSighup = (): void => onSignal('SIGHUP')
|
|
155
|
+
if (args.enabled) {
|
|
156
|
+
process.on('SIGINT', onSigint)
|
|
157
|
+
process.on('SIGTERM', onSigterm)
|
|
158
|
+
process.on('SIGHUP', onSighup)
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
remove: () => {
|
|
162
|
+
process.off('SIGINT', onSigint)
|
|
163
|
+
process.off('SIGTERM', onSigterm)
|
|
164
|
+
process.off('SIGHUP', onSighup)
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
15
15
|
import type { CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
16
|
+
import { isLiteralPattern, normalizeGlob, relPosix } from '../util/index.js'
|
|
17
|
+
import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
|
|
16
18
|
import { computeGroupHash, computeTaskHash, type HashCache } from './task-hash.js'
|
|
19
|
+
import { filterUpstreamHashes, keyUpstream } from './upstream.js'
|
|
17
20
|
|
|
18
21
|
export interface DeriveStableKeysArgs {
|
|
19
22
|
nodes: Map<string, TaskNode>
|
|
@@ -54,12 +57,57 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
54
57
|
// dep's accumulated producers fold forward.
|
|
55
58
|
// - outputProjects: the project names of every upstream task declaring
|
|
56
59
|
// cache.outputs.files (project-relative outputs land in the producer's
|
|
57
|
-
// own dir)
|
|
60
|
+
// own dir), and of every upstream with no cache block that may write
|
|
61
|
+
// in its own project (`undeclaredWriteReach`, item 743). A bitset over
|
|
62
|
+
// project indexes: a union per dep was a string Set copied tasks ×
|
|
63
|
+
// deps × projects times, 101 ms of a 513 ms warm run at 476 packages
|
|
64
|
+
// (item 744).
|
|
58
65
|
// - wsOutputUpstream: any upstream declares cache.outputs.workspaceFiles
|
|
59
|
-
// (root-anchored, boundary-ignoring outputs)
|
|
60
|
-
|
|
66
|
+
// (root-anchored, boundary-ignoring outputs), or has no cache block
|
|
67
|
+
// and a sandbox write grant elsewhere in the workspace, or may rewrite
|
|
68
|
+
// a file the workspace fingerprint folds (a root `pnpm install`): every
|
|
69
|
+
// key folds that digest, taken once per run.
|
|
70
|
+
// - rewritersById / unfoldedById: the projects of every upstream CACHED
|
|
71
|
+
// task that may rewrite its own inputs in place (a formatter declaring
|
|
72
|
+
// no outputs; `commandWriteReach`), and the subset this task's key does
|
|
73
|
+
// not fold. A key that folds the rewriter's covers what it writes — its
|
|
74
|
+
// writes are its inputs', which its key names — so only a reader
|
|
75
|
+
// folding no path to it (`tasks: []`) is preliminary. A rewriter an
|
|
76
|
+
// unfolded dependency covered is still uncovered here: its key is not
|
|
77
|
+
// in this one.
|
|
78
|
+
// - wsRewriters: the same for a cached rewriter whose reach is every
|
|
79
|
+
// project (a write grant elsewhere in the workspace, or a fingerprinted
|
|
80
|
+
// file, `mayWriteFingerprint`); one this key does not fold makes it
|
|
81
|
+
// preliminary. A dependency this key folds needs no pass-through here:
|
|
82
|
+
// a keyed task with such a rewriter unfolded is unstable itself, and
|
|
83
|
+
// instability is inherited below.
|
|
84
|
+
const projects = new ProjectIndex(args.nodes)
|
|
85
|
+
const outputProjectsById = new Map<string, ProjectSet>()
|
|
86
|
+
const rewritersById = new Map<string, ProjectSet>()
|
|
87
|
+
const unfoldedById = new Map<string, ProjectSet>()
|
|
88
|
+
const wsRewriters = new Set<string>()
|
|
89
|
+
// A cached rewriter matters only to a key that leaves a dependency's key
|
|
90
|
+
// out: a `cache.inputs.tasks` filter, or a persistent dependency, which
|
|
91
|
+
// has no key. With neither in the graph every edge folds and the sets
|
|
92
|
+
// above stay empty, so they are not built (about 2 ms, median, of a 27 ms
|
|
93
|
+
// memoised walk over the 3,000-task bench; item 750).
|
|
94
|
+
let anyUnfolded = false
|
|
95
|
+
for (const n of args.nodes.values()) {
|
|
96
|
+
if (n.config.cache?.inputs?.tasks !== undefined || n.config.exec?.persistent !== undefined) {
|
|
97
|
+
anyUnfolded = true
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
}
|
|
61
101
|
const wsOutputUpstreamById = new Map<string, boolean>()
|
|
62
102
|
const stableKeys: StableKey[] = []
|
|
103
|
+
// Workspace-relative project dirs, for the reach test of a
|
|
104
|
+
// `workspaceFiles` reader against its upstream producers.
|
|
105
|
+
const dirByProject = new Map<string, string>()
|
|
106
|
+
for (const node of args.nodes.values()) {
|
|
107
|
+
if (!dirByProject.has(node.projectName)) {
|
|
108
|
+
dirByProject.set(node.projectName, relPosix(args.workspaceRoot, node.projectDir))
|
|
109
|
+
}
|
|
110
|
+
}
|
|
63
111
|
|
|
64
112
|
for (const id of order) {
|
|
65
113
|
const node = args.nodes.get(id)!
|
|
@@ -67,19 +115,68 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
67
115
|
|
|
68
116
|
// Fold every dep's accumulated producers + the dep's own declared
|
|
69
117
|
// outputs into this node's transitive-upstream producer sets.
|
|
70
|
-
const outputProjects =
|
|
118
|
+
const outputProjects = projects.empty()
|
|
119
|
+
let rewriters: ProjectSet | undefined
|
|
120
|
+
let unfolded: ProjectSet | undefined
|
|
121
|
+
let folds: ((dep: string) => boolean) | undefined
|
|
122
|
+
let wsRewriter = false
|
|
123
|
+
let wsUnfoldedHere = false
|
|
71
124
|
let wsOutputUpstream = false
|
|
72
125
|
for (const dep of node.deps) {
|
|
73
126
|
const depNode = args.nodes.get(dep)
|
|
74
127
|
if (!depNode) continue
|
|
75
|
-
|
|
128
|
+
const upstreamOfDep = outputProjectsById.get(dep)
|
|
129
|
+
if (upstreamOfDep !== undefined) outputProjects.addAll(upstreamOfDep)
|
|
76
130
|
if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
|
|
77
131
|
const depOut = depNode.config.cache?.outputs
|
|
78
132
|
if ((depOut?.files?.length ?? 0) > 0) outputProjects.add(depNode.projectName)
|
|
79
133
|
if ((depOut?.workspaceFiles?.length ?? 0) > 0) wsOutputUpstream = true
|
|
134
|
+
// An uncached producer declares no outputs, yet a same-project reader
|
|
135
|
+
// after it (`tasks: []` decouples the key, not the order) read the
|
|
136
|
+
// bytes it wrote: its key taken up front was the pre-producer one, and
|
|
137
|
+
// the fourth run of seeds A,B,B,A replayed B over A (turborepo#13788).
|
|
138
|
+
const reach = undeclaredWriteReach(depNode, args.workspaceRoot)
|
|
139
|
+
if (reach === 'project') outputProjects.add(depNode.projectName)
|
|
140
|
+
else if (reach === 'workspace') wsOutputUpstream = true
|
|
141
|
+
const cached = depNode.config.cache !== undefined
|
|
142
|
+
if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot)) wsOutputUpstream = true
|
|
143
|
+
if (!anyUnfolded) continue
|
|
144
|
+
// The cached twin: a formatter rewriting its own input ahead of a
|
|
145
|
+
// reader that folds no key of it replayed seed B's build under seed A
|
|
146
|
+
// on the fourth run, the same way (item 750).
|
|
147
|
+
const rewrites = cached ? commandWriteReach(depNode, args.workspaceRoot) : 'none'
|
|
148
|
+
const rewritesWs =
|
|
149
|
+
cached && (rewrites === 'workspace' || mayWriteFingerprint(depNode, args.workspaceRoot))
|
|
150
|
+
const rewritersOfDep = rewritersById.get(dep)
|
|
151
|
+
const wsRewriterOfDep = wsRewriters.has(dep)
|
|
152
|
+
if (
|
|
153
|
+
rewritersOfDep === undefined &&
|
|
154
|
+
rewrites !== 'project' &&
|
|
155
|
+
!wsRewriterOfDep &&
|
|
156
|
+
!rewritesWs
|
|
157
|
+
) {
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
rewriters ??= projects.empty()
|
|
161
|
+
if (rewritersOfDep !== undefined) rewriters.addAll(rewritersOfDep)
|
|
162
|
+
if (rewrites === 'project') rewriters.add(depNode.projectName)
|
|
163
|
+
if (wsRewriterOfDep || rewritesWs) wsRewriter = true
|
|
164
|
+
folds ??= foldedBy(node, upstream, keyById)
|
|
165
|
+
if (folds(dep)) {
|
|
166
|
+
const unfoldedOfDep = unfoldedById.get(dep)
|
|
167
|
+
if (unfoldedOfDep !== undefined) (unfolded ??= projects.empty()).addAll(unfoldedOfDep)
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
unfolded ??= projects.empty()
|
|
171
|
+
if (rewritersOfDep !== undefined) unfolded.addAll(rewritersOfDep)
|
|
172
|
+
if (rewrites === 'project') unfolded.add(depNode.projectName)
|
|
173
|
+
if (wsRewriterOfDep || rewritesWs) wsUnfoldedHere = true
|
|
80
174
|
}
|
|
81
175
|
outputProjectsById.set(id, outputProjects)
|
|
82
176
|
wsOutputUpstreamById.set(id, wsOutputUpstream)
|
|
177
|
+
if (rewriters !== undefined) rewritersById.set(id, rewriters)
|
|
178
|
+
if (unfolded !== undefined) unfoldedById.set(id, unfolded)
|
|
179
|
+
if (wsRewriter) wsRewriters.add(id)
|
|
83
180
|
|
|
84
181
|
if (isGroupTask(node)) {
|
|
85
182
|
// Groups have no exec/cache; they only fold upstream keys so
|
|
@@ -91,6 +188,12 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
91
188
|
continue
|
|
92
189
|
}
|
|
93
190
|
|
|
191
|
+
// A persistent task has no key on the live path (its outcome carries no
|
|
192
|
+
// hash, so a dependent folds nothing of it); recording one here gave
|
|
193
|
+
// its dependents a second key, which a `--force` or remote run never
|
|
194
|
+
// saved under.
|
|
195
|
+
if (node.config.exec?.persistent !== undefined) continue
|
|
196
|
+
|
|
94
197
|
const hash = await computeTaskHash({
|
|
95
198
|
node,
|
|
96
199
|
upstream,
|
|
@@ -106,7 +209,12 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
106
209
|
|
|
107
210
|
const unstable =
|
|
108
211
|
node.deps.some((d) => unstableById.has(d)) ||
|
|
109
|
-
dependsOnSiblingOutputs(
|
|
212
|
+
dependsOnSiblingOutputs(
|
|
213
|
+
node,
|
|
214
|
+
unfolded === undefined ? outputProjects : unfolded.or(outputProjects),
|
|
215
|
+
wsOutputUpstream || wsUnfoldedHere,
|
|
216
|
+
dirByProject,
|
|
217
|
+
)
|
|
110
218
|
if (unstable) unstableById.add(id)
|
|
111
219
|
|
|
112
220
|
const cacheEnabled = node.config.cache !== undefined
|
|
@@ -115,6 +223,28 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
115
223
|
return stableKeys
|
|
116
224
|
}
|
|
117
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Does this task's key fold the dependency's? What `computeTaskHash` folds
|
|
228
|
+
* (`filterUpstreamHashes` over the same synthetic outcomes); a group folds
|
|
229
|
+
* every keyed member. A dependency with no key (persistent) is folded by
|
|
230
|
+
* none.
|
|
231
|
+
*/
|
|
232
|
+
function foldedBy(
|
|
233
|
+
node: TaskNode,
|
|
234
|
+
upstream: TaskOutcome[],
|
|
235
|
+
keyById: Map<string, string>,
|
|
236
|
+
): (dep: string) => boolean {
|
|
237
|
+
const filter = node.config.cache?.inputs?.tasks
|
|
238
|
+
if (isGroupTask(node) || filter === undefined) return (dep) => keyById.has(dep)
|
|
239
|
+
const hashes = new Set(
|
|
240
|
+
filterUpstreamHashes(upstream, filter, node.projectName, node.id).map(([, h]) => h),
|
|
241
|
+
)
|
|
242
|
+
return (dep) => {
|
|
243
|
+
const h = keyById.get(dep)
|
|
244
|
+
return h !== undefined && hashes.has(h)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
118
248
|
/**
|
|
119
249
|
* Synthetic upstream outcomes for key derivation. Only `node` + `hash`
|
|
120
250
|
* are read by filterUpstreamHashes / computeGroupHash; the rest of
|
|
@@ -137,7 +267,7 @@ function synthUpstream(
|
|
|
137
267
|
hash: keyById.get(dep),
|
|
138
268
|
} as TaskOutcome)
|
|
139
269
|
}
|
|
140
|
-
return out
|
|
270
|
+
return keyUpstream(node, out)
|
|
141
271
|
}
|
|
142
272
|
|
|
143
273
|
/**
|
|
@@ -167,8 +297,9 @@ function synthUpstream(
|
|
|
167
297
|
*/
|
|
168
298
|
export function dependsOnSiblingOutputs(
|
|
169
299
|
node: TaskNode,
|
|
170
|
-
upstreamOutputProjects:
|
|
300
|
+
upstreamOutputProjects: ProjectNames,
|
|
171
301
|
hasWsOutputUpstream: boolean,
|
|
302
|
+
dirByProject?: ReadonlyMap<string, string>,
|
|
172
303
|
): boolean {
|
|
173
304
|
const cache = node.config.cache
|
|
174
305
|
// A cache-disabled task has no key to prefetch anyway; treat as
|
|
@@ -184,11 +315,129 @@ export function dependsOnSiblingOutputs(
|
|
|
184
315
|
// `app` read `gen/**` project-relative), so the mere presence of a
|
|
185
316
|
// workspace-output producer upstream makes the key preliminary.
|
|
186
317
|
if (hasWsOutputUpstream) return true
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
318
|
+
const wsInputs = cache.inputs?.workspaceFiles ?? []
|
|
319
|
+
if (wsInputs.length > 0 && upstreamOutputProjects.size > 0) {
|
|
320
|
+
// Without the dirs the old answer stands: a workspace reader over any
|
|
321
|
+
// producer is preliminary. With them, only a reader whose globs can
|
|
322
|
+
// reach a producer's directory is. A Turbo-mapped workspace gives every
|
|
323
|
+
// task its `globalDependencies` as root literals (`turbo.json`), which
|
|
324
|
+
// reach no package — classing those as unstable hashed medusa's 83
|
|
325
|
+
// tasks twice and probed 76 of them lazily (2026-09-11).
|
|
326
|
+
if (dirByProject === undefined) return true
|
|
327
|
+
const dirs: string[] = []
|
|
328
|
+
for (const p of upstreamOutputProjects) {
|
|
329
|
+
const d = dirByProject.get(p)
|
|
330
|
+
if (d === undefined) return true
|
|
331
|
+
dirs.push(d)
|
|
332
|
+
}
|
|
333
|
+
return workspaceInputsReach(wsInputs, dirs)
|
|
334
|
+
}
|
|
335
|
+
return false
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Can any positive `workspaceFiles` entry match a path inside one of
|
|
340
|
+
* `dirs` (workspace-relative project dirs)? An entry's reach is its
|
|
341
|
+
* literal prefix — the segments before the first one holding a glob
|
|
342
|
+
* metacharacter — and it reaches a dir when either is the other's
|
|
343
|
+
* ancestor or equal. An entry with no literal prefix (`**\/*`) reaches
|
|
344
|
+
* everything. Conservative on every unknown spelling.
|
|
345
|
+
*/
|
|
346
|
+
export function workspaceInputsReach(
|
|
347
|
+
workspaceFiles: readonly string[],
|
|
348
|
+
dirs: readonly string[],
|
|
349
|
+
): boolean {
|
|
350
|
+
for (const raw of workspaceFiles) {
|
|
351
|
+
if (raw.startsWith('!')) continue
|
|
352
|
+
const entry = normalizeGlob(raw)
|
|
353
|
+
const segments = entry.split('/')
|
|
354
|
+
const literal: string[] = []
|
|
355
|
+
for (const seg of segments) {
|
|
356
|
+
if (!isLiteralPattern(seg)) break
|
|
357
|
+
literal.push(seg)
|
|
358
|
+
}
|
|
359
|
+
if (literal.length === 0) return true
|
|
360
|
+
const prefix = literal.join('/')
|
|
361
|
+
for (const dir of dirs) {
|
|
362
|
+
if (dir === '' || dir === '.') return true
|
|
363
|
+
if (prefix === dir || prefix.startsWith(`${dir}/`) || dir.startsWith(`${prefix}/`))
|
|
364
|
+
return true
|
|
365
|
+
}
|
|
366
|
+
}
|
|
189
367
|
return false
|
|
190
368
|
}
|
|
191
369
|
|
|
370
|
+
/** What the stability gate reads of a set of project names. */
|
|
371
|
+
interface ProjectNames extends Iterable<string> {
|
|
372
|
+
has(name: string): boolean
|
|
373
|
+
readonly size: number
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Every project of the graph, numbered, so a set of them is a bitset. */
|
|
377
|
+
class ProjectIndex {
|
|
378
|
+
readonly names: string[] = []
|
|
379
|
+
readonly indexOf = new Map<string, number>()
|
|
380
|
+
readonly words: number
|
|
381
|
+
|
|
382
|
+
constructor(nodes: Map<string, TaskNode>) {
|
|
383
|
+
for (const node of nodes.values()) {
|
|
384
|
+
if (this.indexOf.has(node.projectName)) continue
|
|
385
|
+
this.indexOf.set(node.projectName, this.names.length)
|
|
386
|
+
this.names.push(node.projectName)
|
|
387
|
+
}
|
|
388
|
+
this.words = (this.names.length + 31) >>> 5
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
empty(): ProjectSet {
|
|
392
|
+
return new ProjectSet(this, new Uint32Array(this.words))
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
class ProjectSet implements ProjectNames {
|
|
397
|
+
constructor(
|
|
398
|
+
private readonly index: ProjectIndex,
|
|
399
|
+
private readonly bits: Uint32Array,
|
|
400
|
+
) {}
|
|
401
|
+
|
|
402
|
+
add(name: string): void {
|
|
403
|
+
const i = this.index.indexOf.get(name)!
|
|
404
|
+
this.bits[i >>> 5]! |= 1 << (i & 31)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
addAll(other: ProjectSet): void {
|
|
408
|
+
for (let w = 0; w < this.bits.length; w++) this.bits[w]! |= other.bits[w]!
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
or(other: ProjectSet): ProjectSet {
|
|
412
|
+
const bits = this.bits.slice()
|
|
413
|
+
for (let w = 0; w < bits.length; w++) bits[w]! |= other.bits[w]!
|
|
414
|
+
return new ProjectSet(this.index, bits)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
has(name: string): boolean {
|
|
418
|
+
const i = this.index.indexOf.get(name)
|
|
419
|
+
return i !== undefined && (this.bits[i >>> 5]! & (1 << (i & 31))) !== 0
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
get size(): number {
|
|
423
|
+
let n = 0
|
|
424
|
+
for (const word of this.bits) {
|
|
425
|
+
let v = word
|
|
426
|
+
while (v !== 0) {
|
|
427
|
+
v &= v - 1
|
|
428
|
+
n++
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return n
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
*[Symbol.iterator](): Iterator<string> {
|
|
435
|
+
for (let i = 0; i < this.index.names.length; i++) {
|
|
436
|
+
if ((this.bits[i >>> 5]! & (1 << (i & 31))) !== 0) yield this.index.names[i]!
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
192
441
|
function topoOrder(nodes: Map<string, TaskNode>): string[] {
|
|
193
442
|
const indegree = new Map<string, number>()
|
|
194
443
|
const dependents = new Map<string, string[]>()
|