@vzn/vx 0.0.21 → 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/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +611 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -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/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// One run at a time per workspace, on this machine.
|
|
2
|
+
//
|
|
3
|
+
// Two vx processes on one workspace race on every task's OUTPUT TREE: both
|
|
4
|
+
// clean and restore the same `dist/`, and a clean landing while the other
|
|
5
|
+
// run's restore is staging takes its files out from under it (item 215).
|
|
6
|
+
// The cache itself is safe (SQLite waits, artifacts land by rename); the
|
|
7
|
+
// tree is not, and a run that finished "green" can have had its restored
|
|
8
|
+
// outputs deleted by the other run's clean a moment later. So a run takes
|
|
9
|
+
// this lock before it schedules and releases it with its cache handle —
|
|
10
|
+
// before a persistent task's wait, so a dev server never holds it.
|
|
11
|
+
//
|
|
12
|
+
// Keyed by the workspace, not the cache directory (`--cache-dir` must not
|
|
13
|
+
// make two runs strangers), and kept under the temp directory so a
|
|
14
|
+
// read-only checkout can take it.
|
|
15
|
+
//
|
|
16
|
+
// The lock is a directory, HELD exactly while it is not empty. It holds
|
|
17
|
+
// one entry, `h-<pid>-<start>-<n>`, naming the holder and unique to this
|
|
18
|
+
// taking of it. It is built beside its name and renamed into place, and a
|
|
19
|
+
// rename onto a directory succeeds only where that directory is absent or
|
|
20
|
+
// empty: the atomic "take it if it is free". Leaving it, or reclaiming it
|
|
21
|
+
// from a holder that died, unlinks that one ENTRY by name, and an unlink
|
|
22
|
+
// of an entry another taking wrote cannot succeed, so a reclaim judged on
|
|
23
|
+
// one holder never removes the next one's lock. The empty directory left
|
|
24
|
+
// behind is free, and goes with a best-effort rmdir that fails on anything
|
|
25
|
+
// holding an entry.
|
|
26
|
+
//
|
|
27
|
+
// It was a bare `mkdir`, then a `pid` file, left as an unlink of the file
|
|
28
|
+
// and then the rmdir, and reclaimed by `rm -r`: visible without its pid
|
|
29
|
+
// for a moment each way, and a waiter that had waited past its grace
|
|
30
|
+
// removed that moment as abandoned. Four processes contending for four
|
|
31
|
+
// seconds held it two at once 22 times, and a release threw ENOENT out of
|
|
32
|
+
// `run()`. Moving a dead holder's lock aside and putting back what was not
|
|
33
|
+
// it only narrowed that: a third run could take the name in between, and
|
|
34
|
+
// with holders killed mid-hold it did in four stress runs of ten (item
|
|
35
|
+
// 759). A `pid` file an older vx wrote is still read and reclaimed; an
|
|
36
|
+
// older vx does not read this entry, so the two versions exclude each
|
|
37
|
+
// other only as far as the older one's own lock did.
|
|
38
|
+
//
|
|
39
|
+
// A holder's pid can come back: the temp directory outlives a container
|
|
40
|
+
// restart, and the restarted container's vx got the dead run's pid (1) and
|
|
41
|
+
// waited for itself forever (nx#36473 reproduced on vx, 2026-09-24). So a
|
|
42
|
+
// lock naming OUR pid is stale — a run of this process would be in
|
|
43
|
+
// `heldHere` — and on Linux the entry also carries the holder's start
|
|
44
|
+
// time, so a pid another process now wears is stale too. Where the lock
|
|
45
|
+
// cannot be made or read for any reason but "held" (a temp directory this
|
|
46
|
+
// user cannot write), the run says so once and proceeds unlocked: the
|
|
47
|
+
// lock is a courtesy between cooperating runs, and refusing to run would
|
|
48
|
+
// be worse than the race. Machines sharing a workspace over a network file
|
|
49
|
+
// system do not share `/tmp`, so they do not share this; nor do two shells
|
|
50
|
+
// whose `TMPDIR` differs (`nix develop` sets its own, item 970). Kept here
|
|
51
|
+
// anyway: under the workspace the lock would outlive a reboot, and off
|
|
52
|
+
// Linux, where an entry names no start time, a reused pid would read as a
|
|
53
|
+
// live holder for good.
|
|
54
|
+
//
|
|
55
|
+
// Runs inside ONE process share the lock: an embedder that runs two at
|
|
56
|
+
// once coordinates them itself (`RunOptions.inflight` joins duplicate
|
|
57
|
+
// tasks across them), and making them wait for each other would only
|
|
58
|
+
// serialize what it chose to overlap. The lock is taken by the first of
|
|
59
|
+
// them and left by the last to release.
|
|
60
|
+
|
|
61
|
+
import { readFileSync, realpathSync } from 'node:fs'
|
|
62
|
+
import { rmdirSync, unlinkSync } from 'node:fs'
|
|
63
|
+
import { mkdir, readdir, readFile, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises'
|
|
64
|
+
import os from 'node:os'
|
|
65
|
+
import path from 'node:path'
|
|
66
|
+
import { isTmpdirRefusal, procfsIsOwn, TMPDIR_HINT, xxh3hex } from '../util/index.js'
|
|
67
|
+
|
|
68
|
+
/** Runs in this process currently holding the lock, per lock directory. */
|
|
69
|
+
const heldHere = new Map<string, number>()
|
|
70
|
+
/** The entry this process's taking of each held lock wrote. */
|
|
71
|
+
const takers = new Map<string, string>()
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A signal exit is `process.exit` (signals.ts): no finally runs and nothing
|
|
75
|
+
* is awaited, so a Ctrl-C left this process's entry, and the lock
|
|
76
|
+
* directory, in the temp dir for the next run to reclaim (item 848). The
|
|
77
|
+
* process's own `exit` event is the one step every exit path passes, and
|
|
78
|
+
* it runs synchronously.
|
|
79
|
+
*/
|
|
80
|
+
let exitHooked = false
|
|
81
|
+
function releaseOnExit(): void {
|
|
82
|
+
if (exitHooked) return
|
|
83
|
+
exitHooked = true
|
|
84
|
+
process.on('exit', () => {
|
|
85
|
+
for (const [lockDir, entry] of takers) {
|
|
86
|
+
// The entry may be gone already: a release under way unlinked it and
|
|
87
|
+
// was still to remove the directory (item 867).
|
|
88
|
+
try {
|
|
89
|
+
unlinkSync(path.join(lockDir, entry))
|
|
90
|
+
} catch {
|
|
91
|
+
// reclaimed already, or released
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
rmdirSync(lockDir)
|
|
95
|
+
} catch {
|
|
96
|
+
// another run took the emptied name: its entry is in it
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Polling cadence while another run holds the lock. */
|
|
103
|
+
const POLL_MS = 50
|
|
104
|
+
/** How long a wait stays silent before the run says whom it is waiting for. */
|
|
105
|
+
const SAY_AFTER_MS = 1_000
|
|
106
|
+
|
|
107
|
+
/** An older vx's holder file: its pid and, on Linux, its start time. */
|
|
108
|
+
const LEGACY_PID = 'pid'
|
|
109
|
+
|
|
110
|
+
export interface RunLockOptions {
|
|
111
|
+
/** Where the lock directories live; `os.tmpdir()` unless a test says otherwise. */
|
|
112
|
+
dir?: string
|
|
113
|
+
/** A status line for the waiting notice and the unlocked warning. */
|
|
114
|
+
log: (line: string) => void
|
|
115
|
+
/** Abort the wait (Ctrl-C, an embedder's signal). */
|
|
116
|
+
signal?: AbortSignal | undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The lock directory for a workspace root: stable across runs and users,
|
|
121
|
+
* private to this machine. Keyed by the REAL path: a CLI's cwd comes back
|
|
122
|
+
* canonical while a caller may hold a symlinked spelling (macOS's /var ->
|
|
123
|
+
* /private/var), and two spellings of one workspace must meet.
|
|
124
|
+
*/
|
|
125
|
+
export function runLockPath(workspaceRoot: string, dir = os.tmpdir()): string {
|
|
126
|
+
const resolved = path.resolve(workspaceRoot)
|
|
127
|
+
let real = resolved
|
|
128
|
+
try {
|
|
129
|
+
real = realpathSync(resolved)
|
|
130
|
+
} catch {
|
|
131
|
+
// A root that is not there yet (a test's placeholder) keys by its spelling.
|
|
132
|
+
}
|
|
133
|
+
return path.join(dir, `vx-run-${xxh3hex(real)}`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface Holder {
|
|
137
|
+
pid: number
|
|
138
|
+
/** The holder's start time as `startTime` read it; null where the lock names none. */
|
|
139
|
+
start: string | null
|
|
140
|
+
/** The entry in the lock directory that names it. */
|
|
141
|
+
entry: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const ENTRY = /^h-(\d+)-(\d+|x)-\d+$/
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Who holds the lock: 'free' when there is no lock or an empty one, null
|
|
148
|
+
* when it holds nothing read as a holder (an older vx between its mkdir
|
|
149
|
+
* and its pid write).
|
|
150
|
+
*/
|
|
151
|
+
async function holder(lockDir: string): Promise<Holder | 'free' | null> {
|
|
152
|
+
let entries: string[]
|
|
153
|
+
try {
|
|
154
|
+
entries = await readdir(lockDir)
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 'free'
|
|
157
|
+
throw err
|
|
158
|
+
}
|
|
159
|
+
if (entries.length === 0) return 'free'
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
const m = ENTRY.exec(entry)
|
|
162
|
+
if (m !== null) return { pid: Number(m[1]), start: m[2] === 'x' ? null : m[2]!, entry }
|
|
163
|
+
}
|
|
164
|
+
if (!entries.includes(LEGACY_PID)) return null
|
|
165
|
+
try {
|
|
166
|
+
const text = await readFile(path.join(lockDir, LEGACY_PID), 'utf8')
|
|
167
|
+
const [pidText, start] = text.trim().split(' ')
|
|
168
|
+
const pid = Number.parseInt(pidText ?? '', 10)
|
|
169
|
+
return Number.isInteger(pid) && pid > 0
|
|
170
|
+
? { pid, start: start ?? null, entry: LEGACY_PID }
|
|
171
|
+
: null
|
|
172
|
+
} catch {
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* When a process started, in clock ticks since boot: field 22 of
|
|
179
|
+
* `/proc/<pid>/stat`. Two processes that wore one pid differ here. Null
|
|
180
|
+
* off Linux, where the answer costs a `ps` spawn per run, under a procfs
|
|
181
|
+
* mounted for another pid namespace, and for a pid procfs does not show.
|
|
182
|
+
*/
|
|
183
|
+
function startTime(pid: number | 'self'): string | null {
|
|
184
|
+
if (!procfsIsOwn()) return null
|
|
185
|
+
try {
|
|
186
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
187
|
+
// Fields after the LAST ')' start at field 3; comm may hold spaces.
|
|
188
|
+
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null
|
|
189
|
+
} catch {
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** This process's pid and start time as an entry names them, read once per process. */
|
|
195
|
+
let ownId: string | undefined
|
|
196
|
+
/** Tells this process's takings of a lock apart, and their staging names. */
|
|
197
|
+
let takings = 0
|
|
198
|
+
function nextEntry(): string {
|
|
199
|
+
ownId ??= `h-${process.pid}-${startTime('self') ?? 'x'}`
|
|
200
|
+
return `${ownId}-${++takings}`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Is the lock's holder a live run of another process? `sameAsLast`: this
|
|
205
|
+
* holder's start time already matched on an earlier poll, so a wait reads
|
|
206
|
+
* procfs once per holder, not once per poll.
|
|
207
|
+
*/
|
|
208
|
+
function holderLive(h: Holder, sameAsLast: boolean): boolean {
|
|
209
|
+
if (h.pid === process.pid) return false
|
|
210
|
+
if (!alive(h.pid)) return false
|
|
211
|
+
if (h.start === null || sameAsLast) return true
|
|
212
|
+
const now = startTime(h.pid)
|
|
213
|
+
return now === null || now === h.start
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function alive(pid: number): boolean {
|
|
217
|
+
try {
|
|
218
|
+
process.kill(pid, 0)
|
|
219
|
+
return true
|
|
220
|
+
} catch (err) {
|
|
221
|
+
// EPERM: the process exists but is another user's — alive, not ours to
|
|
222
|
+
// reclaim. ESRCH: gone.
|
|
223
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Take the lock if it is free: built complete beside its name, then renamed
|
|
229
|
+
* onto it. Resolves to the entry that names this taking, or null when
|
|
230
|
+
* another run holds it.
|
|
231
|
+
*/
|
|
232
|
+
async function place(lockDir: string): Promise<string | null> {
|
|
233
|
+
const entry = nextEntry()
|
|
234
|
+
// Random too: where procfs is not this namespace's the entry names no
|
|
235
|
+
// start time, and a restarted container's pid 1 would meet the staging
|
|
236
|
+
// directory a killed pid 1 left, and refuse every run after it.
|
|
237
|
+
const staging = `${lockDir}.${entry}.${Math.random().toString(36).slice(2)}`
|
|
238
|
+
await mkdir(staging)
|
|
239
|
+
try {
|
|
240
|
+
await writeFile(path.join(staging, entry), '')
|
|
241
|
+
await rename(staging, lockDir)
|
|
242
|
+
return entry
|
|
243
|
+
} catch (err) {
|
|
244
|
+
await rm(staging, { recursive: true, force: true })
|
|
245
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
246
|
+
if (code === 'ENOTEMPTY' || code === 'EEXIST') return null
|
|
247
|
+
throw err
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Unlink one taking's entry, then the directory if that left it empty.
|
|
253
|
+
* Gone already is not an error (another waiter reclaimed it first); a
|
|
254
|
+
* refusal is (another user's lock), and the caller runs unlocked.
|
|
255
|
+
*/
|
|
256
|
+
async function leave(lockDir: string, entry: string): Promise<void> {
|
|
257
|
+
try {
|
|
258
|
+
await unlink(path.join(lockDir, entry))
|
|
259
|
+
} catch (err) {
|
|
260
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return
|
|
261
|
+
throw err
|
|
262
|
+
}
|
|
263
|
+
// A run that took the emptied name already holds it: ENOTEMPTY, and it stays.
|
|
264
|
+
await rmdir(lockDir).catch(() => {})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Take the workspace's run lock, waiting for a live holder in another
|
|
269
|
+
* process to release it. Resolves to the release function; the caller
|
|
270
|
+
* runs it on every exit path.
|
|
271
|
+
*/
|
|
272
|
+
export async function acquireRunLock(
|
|
273
|
+
workspaceRoot: string,
|
|
274
|
+
opts: RunLockOptions,
|
|
275
|
+
): Promise<() => Promise<void>> {
|
|
276
|
+
const lockDir = runLockPath(workspaceRoot, opts.dir)
|
|
277
|
+
const started = Date.now()
|
|
278
|
+
let said = false
|
|
279
|
+
let lastLive: Holder | undefined
|
|
280
|
+
const release = async (): Promise<void> => {
|
|
281
|
+
const left = (heldHere.get(lockDir) ?? 1) - 1
|
|
282
|
+
if (left > 0) {
|
|
283
|
+
heldHere.set(lockDir, left)
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
heldHere.delete(lockDir)
|
|
287
|
+
const taker = takers.get(lockDir)
|
|
288
|
+
// The unlink of this taking's own entry is the proof no later run
|
|
289
|
+
// reclaimed the lock: had one, the entry is gone and nothing else is
|
|
290
|
+
// touched. A run that joined another's taking in this process leaves
|
|
291
|
+
// through the taker's entry, whichever release comes last. The taker
|
|
292
|
+
// stays listed until the directory is gone: a signal exit between the
|
|
293
|
+
// unlink and the rmdir left the directory behind, the exit hook having
|
|
294
|
+
// nothing to remove (item 867, macOS CI).
|
|
295
|
+
try {
|
|
296
|
+
if (taker !== undefined) await leave(lockDir, taker)
|
|
297
|
+
} finally {
|
|
298
|
+
if (takers.get(lockDir) === taker) takers.delete(lockDir)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
for (;;) {
|
|
302
|
+
if (opts.signal?.aborted === true) return async () => {}
|
|
303
|
+
const here = heldHere.get(lockDir) ?? 0
|
|
304
|
+
if (here > 0) {
|
|
305
|
+
heldHere.set(lockDir, here + 1)
|
|
306
|
+
return release
|
|
307
|
+
}
|
|
308
|
+
let h: Holder | 'free' | null
|
|
309
|
+
try {
|
|
310
|
+
const entry = await place(lockDir)
|
|
311
|
+
if (entry !== null) {
|
|
312
|
+
heldHere.set(lockDir, 1)
|
|
313
|
+
takers.set(lockDir, entry)
|
|
314
|
+
releaseOnExit()
|
|
315
|
+
return release
|
|
316
|
+
}
|
|
317
|
+
h = await holder(lockDir)
|
|
318
|
+
if (h === 'free') continue
|
|
319
|
+
if (h === null) {
|
|
320
|
+
// An older vx's lock with no pid yet: one poll of this wait, then
|
|
321
|
+
// abandoned, and reclaimed whole as that vx did.
|
|
322
|
+
if (Date.now() - started >= POLL_MS) {
|
|
323
|
+
await rm(lockDir, { recursive: true, force: true })
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
} else if (!holderLive(h, lastLive?.entry === h.entry && lastLive.start === h.start)) {
|
|
327
|
+
if (h.entry === LEGACY_PID) await rm(lockDir, { recursive: true, force: true })
|
|
328
|
+
else await leave(lockDir, h.entry)
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
} catch (err) {
|
|
332
|
+
// The lock lives in the temp directory; one that is missing or not
|
|
333
|
+
// writable names its knob (a minimal image, 2026-09-16).
|
|
334
|
+
const hint = isTmpdirRefusal(err) ? `; ${TMPDIR_HINT}` : ''
|
|
335
|
+
opts.log(
|
|
336
|
+
`[vx] no run lock for this workspace (${(err as Error).message}${hint}) — another vx run on it at the same time may race this one`,
|
|
337
|
+
)
|
|
338
|
+
return async () => {}
|
|
339
|
+
}
|
|
340
|
+
if (h !== null) {
|
|
341
|
+
lastLive = h
|
|
342
|
+
if (!said && Date.now() - started >= SAY_AFTER_MS) {
|
|
343
|
+
said = true
|
|
344
|
+
opts.log(`[vx] waiting for another vx run (pid ${h.pid}) on this workspace to finish…`)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
await new Promise((r) => setTimeout(r, POLL_MS))
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -9,7 +9,7 @@ import type { InvocationRecord, RunRecord } from '../cache/index.js'
|
|
|
9
9
|
import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
10
10
|
import { VERSION } from '../version.js'
|
|
11
11
|
import type { CiContext, GitContext, HostContext } from './run-context.js'
|
|
12
|
-
import {
|
|
12
|
+
import { isCacheHit, taskTelemetryOf, type TaskTelemetry } from './telemetry.js'
|
|
13
13
|
|
|
14
14
|
export interface RunRecordsInput {
|
|
15
15
|
outcomes: readonly TaskOutcome[]
|
|
@@ -60,7 +60,7 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
60
60
|
if (isGroupTask(o.node)) continue
|
|
61
61
|
// aborted (killed by a shutdown signal) isn't a real run.
|
|
62
62
|
if (o.status === 'aborted') continue
|
|
63
|
-
if (input.withTelemetry) telemetryTasks.push(
|
|
63
|
+
if (input.withTelemetry) telemetryTasks.push(taskTelemetryOf(o))
|
|
64
64
|
runs.push({
|
|
65
65
|
...(o.hash !== undefined ? { hash: o.hash } : {}),
|
|
66
66
|
project: o.node.projectName,
|
|
@@ -92,6 +92,10 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
92
92
|
cacheHit: isCacheHit(o.status),
|
|
93
93
|
...(o.attempts !== undefined ? { attempts: o.attempts } : {}),
|
|
94
94
|
cached: o.node.config.cache !== undefined,
|
|
95
|
+
...(o.blockedBy !== undefined ? { blockedBy: o.blockedBy } : {}),
|
|
96
|
+
...(o.timedOut === true ? { timedOut: true as const } : {}),
|
|
97
|
+
...(o.sandboxViolations !== undefined ? { sandboxViolations: o.sandboxViolations } : {}),
|
|
98
|
+
...(o.notReady !== undefined ? { notReady: o.notReady } : {}),
|
|
95
99
|
})
|
|
96
100
|
if (o.status === 'failed') failedCount++
|
|
97
101
|
if (o.status === 'cache-hit') hitLocalCount++
|
|
@@ -126,24 +130,3 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
126
130
|
}
|
|
127
131
|
return { runs, invocation, telemetryTasks }
|
|
128
132
|
}
|
|
129
|
-
|
|
130
|
-
function telemetryOf(o: TaskOutcome): TaskTelemetry {
|
|
131
|
-
const t: TaskTelemetry = {
|
|
132
|
-
taskId: o.node.id,
|
|
133
|
-
project: o.node.projectName,
|
|
134
|
-
task: o.node.taskName,
|
|
135
|
-
status: o.status,
|
|
136
|
-
cacheSource: deriveCacheSource(o.status),
|
|
137
|
-
exitCode: o.exitCode,
|
|
138
|
-
durationMs: o.durationMs,
|
|
139
|
-
}
|
|
140
|
-
if (o.hash !== undefined) t.hash = o.hash
|
|
141
|
-
if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
|
|
142
|
-
if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
|
|
143
|
-
if (o.where !== undefined) t.where = o.where
|
|
144
|
-
if (o.outputs !== undefined) t.outputs = o.outputs
|
|
145
|
-
if (o.attempts !== undefined) t.attempts = o.attempts
|
|
146
|
-
if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
|
|
147
|
-
if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
|
|
148
|
-
return t
|
|
149
|
-
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// logger shares that stream, so redirecting it captures the whole run log
|
|
10
10
|
// too. `--report-file` is the sink that carries the property end to end.
|
|
11
11
|
|
|
12
|
-
import type
|
|
12
|
+
import { failedLabel, skippedLabel, type OutcomeView } from './events.js'
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* One finished run, reduced to what a report needs. Used to live in
|
|
@@ -75,7 +75,9 @@ function statusWord(o: OutcomeView): string {
|
|
|
75
75
|
case 'cache-hit-remote':
|
|
76
76
|
return 'success'
|
|
77
77
|
case 'failed':
|
|
78
|
-
return
|
|
78
|
+
return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady)
|
|
79
|
+
case 'skipped':
|
|
80
|
+
return skippedLabel(o.blockedBy)
|
|
79
81
|
default:
|
|
80
82
|
return o.status
|
|
81
83
|
}
|