@vzn/vx 0.0.595 → 0.0.597
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 +1 -1
- package/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +170 -99
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +99 -44
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +9 -6
- package/src/cache/policy.ts +5 -0
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +12 -6
- package/src/cli/foreign-flags.ts +57 -3
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +10 -4
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/run.ts +73 -30
- package/src/cli/select.ts +6 -3
- package/src/cli/show.ts +8 -2
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-filter.ts +63 -12
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch-judge.ts +15 -8
- package/src/cli/watch.ts +18 -11
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +42 -21
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +89 -18
- package/src/exec/proc-sample.ts +1 -1
- package/src/exec/runner.ts +74 -36
- package/src/exec/sandbox-paths.ts +12 -1
- package/src/exec/sandbox-runtime.ts +283 -88
- package/src/exec/sandbox-violations.ts +115 -15
- package/src/graph/scheduler.ts +30 -14
- package/src/graph/task-graph.ts +265 -44
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +85 -39
- package/src/orchestrator/failure-mode.ts +43 -28
- package/src/orchestrator/framed-output.ts +8 -8
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/logger.ts +21 -9
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +9 -13
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +7 -23
- package/src/orchestrator/plugin.ts +37 -16
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +4 -3
- package/src/orchestrator/run-lock.ts +25 -24
- package/src/orchestrator/run.ts +22 -5
- package/src/orchestrator/sandbox-request.ts +96 -18
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +7 -13
- package/src/orchestrator/task-hash.ts +13 -3
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/orchestrator/upstream.ts +11 -0
- package/src/util/hangup.ts +42 -0
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +24 -4
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-eval.ts +27 -6
- package/src/workspace/config-imports.ts +8 -53
- package/src/workspace/config-schema.ts +56 -9
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +5 -3
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +19 -4
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +53 -24
|
@@ -70,8 +70,8 @@ export interface SaveMissArgs {
|
|
|
70
70
|
nestedProjectDirs: string[]
|
|
71
71
|
gitFilesCache?: GitFilesCache | undefined
|
|
72
72
|
/** Declared `cache.outputs.files` / `.workspaceFiles`. */
|
|
73
|
-
outputs: string[]
|
|
74
|
-
wsOutputs: string[]
|
|
73
|
+
outputs: readonly string[]
|
|
74
|
+
wsOutputs: readonly string[]
|
|
75
75
|
/**
|
|
76
76
|
* An ADDITIVE task's own set (item 588): the files its run added or
|
|
77
77
|
* changed under its declared outputs, resolved by execute-task against
|
|
@@ -148,11 +148,9 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
148
148
|
// `cache.outputs` is not a write grant; `exec.sandbox.allow.write` is.
|
|
149
149
|
const sandboxed = node.config.exec?.sandbox !== undefined
|
|
150
150
|
const grantsWrite = (node.config.exec?.sandbox?.allow?.write?.length ?? 0) > 0
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
const linkedOut =
|
|
154
|
-
outputDirLinkedOut(node.projectDir, a.outputs, 'the project') ??
|
|
155
|
-
outputDirLinkedOut(a.workspaceRoot, a.wsOutputs, 'the workspace')
|
|
151
|
+
// A `workspaceFiles` directory linked out of the workspace drops every
|
|
152
|
+
// file (M-65); a project one is refused by the resolver (X-88).
|
|
153
|
+
const linkedOut = outputDirLinkedOut(a.workspaceRoot, a.wsOutputs)
|
|
156
154
|
log.status(
|
|
157
155
|
`[vx] ${node.id}: cache.outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
|
|
158
156
|
`an empty artifact is saved; a later hit restores nothing` +
|
|
@@ -300,15 +298,13 @@ function markWritten(
|
|
|
300
298
|
}
|
|
301
299
|
|
|
302
300
|
/**
|
|
303
|
-
* The first
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* empty-artifact warning blamed the glob (M-61, M-65).
|
|
301
|
+
* The first `workspaceFiles` directory that is a symlink resolving outside
|
|
302
|
+
* the workspace root: every file under it is dropped as outside, and the
|
|
303
|
+
* empty-artifact warning blamed the glob (M-65).
|
|
307
304
|
*/
|
|
308
305
|
function outputDirLinkedOut(
|
|
309
306
|
baseDir: string,
|
|
310
307
|
outputs: readonly string[],
|
|
311
|
-
base: string,
|
|
312
308
|
): { dir: string; target: string; base: string } | undefined {
|
|
313
309
|
const realProject = realpathOrNull(baseDir) ?? baseDir
|
|
314
310
|
// `dist` names the tree `dist/**`: the same reading the resolver gives.
|
|
@@ -326,7 +322,7 @@ function outputDirLinkedOut(
|
|
|
326
322
|
}
|
|
327
323
|
const target = realpathOrNull(abs)
|
|
328
324
|
if (target !== null && target !== realProject && !target.startsWith(realProject + path.sep)) {
|
|
329
|
-
return { dir, target, base }
|
|
325
|
+
return { dir, target, base: 'the workspace' }
|
|
330
326
|
}
|
|
331
327
|
}
|
|
332
328
|
}
|
|
@@ -171,6 +171,13 @@ export interface RunOptions {
|
|
|
171
171
|
* else is treated as the literal file path (cwd-relative).
|
|
172
172
|
*/
|
|
173
173
|
summarize?: string
|
|
174
|
+
/**
|
|
175
|
+
* Text printed just above the run's footer, given the outcomes the
|
|
176
|
+
* footer counts, after the `summaryTable`: nothing prints below the
|
|
177
|
+
* footer (owner), so a caller's end-of-run report (`--report`) rides
|
|
178
|
+
* here. Not called on a refused run.
|
|
179
|
+
*/
|
|
180
|
+
beforeFooter?: (outcomes: readonly TaskOutcome[], ok: boolean) => string
|
|
174
181
|
/**
|
|
175
182
|
* If set, write a Chrome-trace JSON profile of the run's wallclock
|
|
176
183
|
* spans. Path is cwd-relative. Default `profile.json` is selected
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -12,17 +12,12 @@
|
|
|
12
12
|
// side-effect-free pure probes.
|
|
13
13
|
|
|
14
14
|
import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
|
|
15
|
-
import { FULL_CACHE_POLICY } from '../cache/index.js'
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
isGroupTask,
|
|
19
|
-
runGraph,
|
|
20
|
-
type TaskNode,
|
|
21
|
-
type TaskOutcome,
|
|
22
|
-
} from '../graph/index.js'
|
|
15
|
+
import { cachesNothing, FULL_CACHE_POLICY } from '../cache/index.js'
|
|
16
|
+
import { ranNoCache } from './events.js'
|
|
17
|
+
import { idleServers, isGroupTask, runGraph, type TaskNode } from '../graph/index.js'
|
|
23
18
|
import type { HistoryProvider } from './history.js'
|
|
24
19
|
import { computeGroupKey, computeTaskHash } from './task-hash.js'
|
|
25
|
-
import { keyUpstream } from './upstream.js'
|
|
20
|
+
import { keyedOutcome, keyUpstream } from './upstream.js'
|
|
26
21
|
|
|
27
22
|
export type CacheStatus =
|
|
28
23
|
| 'hit-local' // entry exists in local cache
|
|
@@ -156,7 +151,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
156
151
|
}
|
|
157
152
|
if (isGroupTask(node)) {
|
|
158
153
|
cacheStatusById.set(node.id, 'group')
|
|
159
|
-
return
|
|
154
|
+
return keyedOutcome(node, await computeGroupKey(hashArgs))
|
|
160
155
|
}
|
|
161
156
|
|
|
162
157
|
const hash = await computeTaskHash(hashArgs)
|
|
@@ -169,9 +164,8 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
169
164
|
// policy-aware cache layer, which itself respects read gating.
|
|
170
165
|
const policy = args.cachePolicy ?? FULL_CACHE_POLICY
|
|
171
166
|
const reads = policy.localRead || policy.remoteRead
|
|
172
|
-
const writes = policy.localWrite || policy.remoteWrite
|
|
173
167
|
let status: CacheStatus
|
|
174
|
-
if (node
|
|
168
|
+
if (ranNoCache({ node, cacheOff: cachesNothing(policy) })) {
|
|
175
169
|
status = 'no-cache'
|
|
176
170
|
} else if (!reads) {
|
|
177
171
|
status = 'miss'
|
|
@@ -180,7 +174,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
180
174
|
status = where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local'
|
|
181
175
|
}
|
|
182
176
|
cacheStatusById.set(node.id, status)
|
|
183
|
-
return
|
|
177
|
+
return keyedOutcome(node, hash)
|
|
184
178
|
},
|
|
185
179
|
})
|
|
186
180
|
|
|
@@ -303,13 +297,3 @@ function predictPlan(tasks: PlannedTask[]): PlanPrediction {
|
|
|
303
297
|
}
|
|
304
298
|
return { wallMs, workMs, unknownCount }
|
|
305
299
|
}
|
|
306
|
-
|
|
307
|
-
function planOutcome(node: TaskNode, hash: string | undefined): TaskOutcome {
|
|
308
|
-
return {
|
|
309
|
-
node,
|
|
310
|
-
status: 'success',
|
|
311
|
-
exitCode: 0,
|
|
312
|
-
durationMs: 0,
|
|
313
|
-
...(hash !== undefined ? { hash } : {}),
|
|
314
|
-
}
|
|
315
|
-
}
|
|
@@ -142,9 +142,9 @@ export interface VxPlugin {
|
|
|
142
142
|
* many times per run, so it must be cheap and synchronous; a throw, or
|
|
143
143
|
* an answer that is a Promise, is reported once and the plugin admits
|
|
144
144
|
* from then on — a policy never
|
|
145
|
-
* breaks a run. Restore-tier hits
|
|
146
|
-
* local resources
|
|
147
|
-
* must admit. Core keeps no notion of what a task needs: a plugin that
|
|
145
|
+
* breaks a run. Restore-tier hits, tasks on an executor pool and groups
|
|
146
|
+
* hold no local resources: never asked, never in `ctx.running`. When
|
|
147
|
+
* several plugins answer, all must admit. Core keeps no notion of what a task needs: a plugin that
|
|
148
148
|
* packs memory or CPU learns or declares the numbers itself
|
|
149
149
|
* (`@vzn/vx-schedule-history` packs what past executions used).
|
|
150
150
|
*/
|
|
@@ -341,7 +341,12 @@ export interface FingerprintContext extends BaseContext {
|
|
|
341
341
|
}
|
|
342
342
|
|
|
343
343
|
export interface AdmitContext {
|
|
344
|
-
/**
|
|
344
|
+
/**
|
|
345
|
+
* The tasks holding a worker on this machine right now, in dispatch
|
|
346
|
+
* order. A persistent task leaves at ready, when it gives its worker
|
|
347
|
+
* back: it runs until the graph ends, so a policy that counted it would
|
|
348
|
+
* hold its dependants with no completion left to ask again.
|
|
349
|
+
*/
|
|
345
350
|
readonly running: readonly TaskNode[]
|
|
346
351
|
/** The run's worker count — the ceiling the count gate already applies. */
|
|
347
352
|
readonly concurrency: number
|
|
@@ -601,7 +606,8 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
601
606
|
const { plugins, bus, workspaceRoot, cacheDir } = args
|
|
602
607
|
const warn = args.warn ?? ((m) => console.error(m))
|
|
603
608
|
const disposers: Array<() => void> = []
|
|
604
|
-
|
|
609
|
+
// By object, not name: one package can export two plugins.
|
|
610
|
+
const disabled = new Set<Plugin>()
|
|
605
611
|
|
|
606
612
|
for (const plugin of plugins) {
|
|
607
613
|
if (typeof plugin.name !== 'string' || plugin.name.length === 0) {
|
|
@@ -615,6 +621,13 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
615
621
|
throw new UserError(`plugin '${plugin.name}' setup is not a function`)
|
|
616
622
|
}
|
|
617
623
|
|
|
624
|
+
const fail = (where: string, err: unknown): void => {
|
|
625
|
+
if (disabled.has(plugin)) return
|
|
626
|
+
disabled.add(plugin)
|
|
627
|
+
warn(
|
|
628
|
+
`[vx] plugin '${plugin.name}' threw in ${where}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
|
|
629
|
+
)
|
|
630
|
+
}
|
|
618
631
|
const ctx: PluginContext = {
|
|
619
632
|
workspaceRoot,
|
|
620
633
|
cacheDir,
|
|
@@ -625,7 +638,18 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
625
638
|
bus: {
|
|
626
639
|
emit: (event) => bus.emit(event),
|
|
627
640
|
subscribe(subscriber) {
|
|
628
|
-
|
|
641
|
+
// Isolated like an `on` handler: the bus itself swallows a throw.
|
|
642
|
+
const dispose = bus.subscribe((event) => {
|
|
643
|
+
if (disabled.has(plugin)) return
|
|
644
|
+
let ret: unknown
|
|
645
|
+
try {
|
|
646
|
+
ret = subscriber(event)
|
|
647
|
+
} catch (err) {
|
|
648
|
+
fail('a bus subscriber', err)
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
if (ret instanceof Promise) ret.catch((err: unknown) => fail('a bus subscriber', err))
|
|
652
|
+
})
|
|
629
653
|
disposers.push(dispose)
|
|
630
654
|
return dispose
|
|
631
655
|
},
|
|
@@ -638,15 +662,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
638
662
|
`ctx.on: unknown hook '${String(hook)}' (one of ${Object.keys(HOOK_NAMES).join(', ')})`,
|
|
639
663
|
)
|
|
640
664
|
}
|
|
641
|
-
const
|
|
642
|
-
if (disabled.has(plugin.name)) return
|
|
643
|
-
disabled.add(plugin.name)
|
|
644
|
-
warn(
|
|
645
|
-
`[vx] plugin '${plugin.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
|
|
646
|
-
)
|
|
647
|
-
}
|
|
665
|
+
const onFail = (err: unknown): void => fail(hook, err)
|
|
648
666
|
const dispose = bus.subscribe((event) => {
|
|
649
|
-
if (disabled.has(plugin
|
|
667
|
+
if (disabled.has(plugin)) return
|
|
650
668
|
// Not awaited: the bus is synchronous and plugin work runs off the
|
|
651
669
|
// critical path. A handler's rejection is caught like its throw.
|
|
652
670
|
let ret: unknown
|
|
@@ -681,10 +699,10 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
681
699
|
break
|
|
682
700
|
}
|
|
683
701
|
} catch (err) {
|
|
684
|
-
|
|
702
|
+
onFail(err)
|
|
685
703
|
return
|
|
686
704
|
}
|
|
687
|
-
if (ret instanceof Promise) ret.catch(
|
|
705
|
+
if (ret instanceof Promise) ret.catch(onFail)
|
|
688
706
|
})
|
|
689
707
|
disposers.push(dispose)
|
|
690
708
|
},
|
|
@@ -693,6 +711,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
693
711
|
try {
|
|
694
712
|
await plugin.setup(ctx)
|
|
695
713
|
} catch (err) {
|
|
714
|
+
// Earlier plugins' subscriptions too: the caller never gets the
|
|
715
|
+
// disposer, and a bus that outlives the run would keep them.
|
|
716
|
+
for (const d of disposers) d()
|
|
696
717
|
throw new PluginSetupError(
|
|
697
718
|
plugin,
|
|
698
719
|
`plugin '${plugin.name}' failed in setup: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -368,8 +368,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
368
368
|
? options.projects.filter((p) => projects.has(p))
|
|
369
369
|
: [...projects.keys()]
|
|
370
370
|
|
|
371
|
-
|
|
372
|
-
|
|
371
|
+
// A bare name skips the default `build`: it runs nothing, and matched,
|
|
372
|
+
// `vx run build` in a project declaring none ran 0 tasks, exit 0 (X-102).
|
|
373
|
+
// A dependant's `^build` still reaches it, and `lib#build` names it.
|
|
374
|
+
const requested = expandRequested(tasks, candidateProjects, projects, isDefaultBuild)
|
|
375
|
+
let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects, isDefaultBuild)
|
|
373
376
|
if (options.selectedByDiff === true && unresolvedTasks.some((t) => !t.includes('#'))) {
|
|
374
377
|
// A name a loaded project declares is no typo, whether or not the load
|
|
375
378
|
// was whole: an unaffected dependency loaded for the closure declared
|
|
@@ -750,14 +753,20 @@ async function declaredNowhere(
|
|
|
750
753
|
return undeclaredIn(unresolved, all.projects)
|
|
751
754
|
}
|
|
752
755
|
|
|
753
|
-
/**
|
|
756
|
+
/**
|
|
757
|
+
* The bare names in `unresolved` no project in `projects` declares; `a#b`
|
|
758
|
+
* forms pass through. Every project has a `build`, its default one if
|
|
759
|
+
* nothing declares it, and a bare name does not select that (X-102).
|
|
760
|
+
*/
|
|
754
761
|
function undeclaredIn(
|
|
755
762
|
unresolved: readonly string[],
|
|
756
763
|
projects: LoadedProjects['projects'],
|
|
757
764
|
): string[] {
|
|
758
765
|
const declared = new Set<string>()
|
|
759
766
|
for (const p of projects.values()) {
|
|
760
|
-
for (const t of Object.
|
|
767
|
+
for (const [t, task] of Object.entries(p.config.tasks ?? {})) {
|
|
768
|
+
if (!isDefaultBuild(task)) declared.add(t)
|
|
769
|
+
}
|
|
761
770
|
}
|
|
762
771
|
return unresolved.filter((t) => t.includes('#') || !declared.has(t))
|
|
763
772
|
}
|
|
@@ -18,6 +18,7 @@ import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
|
18
18
|
import { tallyOutcomes, type Tally } from './tally.js'
|
|
19
19
|
import type { FlakyFinding } from './failure-mode.js'
|
|
20
20
|
import { isCacheHit } from './telemetry.js'
|
|
21
|
+
import { ranNoCache } from './events.js'
|
|
21
22
|
|
|
22
23
|
export interface SummarizeArgs {
|
|
23
24
|
/** Empty string → default path; otherwise the explicit file path. */
|
|
@@ -92,9 +93,9 @@ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
|
|
|
92
93
|
// the flag a consumer computing a hit rate cannot tell a miss from a
|
|
93
94
|
// task that could never hit (its `hash` is still set — dependents fold
|
|
94
95
|
// it). Present only when true, so every other row is byte-identical.
|
|
95
|
-
...(o
|
|
96
|
-
// Present only when the run proved the task flaky: this exact key
|
|
97
|
-
//
|
|
96
|
+
...(ranNoCache(o) ? { noCache: true } : {}),
|
|
97
|
+
// Present only when the run proved the task flaky: this exact key
|
|
98
|
+
// failed after it had passed (this run counted), or the task
|
|
98
99
|
// needed a retry. A consumer gating on `failed` can tell a break from a
|
|
99
100
|
// flake without the history; every other row is byte-identical.
|
|
100
101
|
...(flaky !== undefined
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
// waited for itself forever (nx#36473 reproduced on vx, 2026-09-24). So a
|
|
44
44
|
// lock naming OUR pid is stale unless this process placed that entry and
|
|
45
45
|
// has not left it (`placed`) — and on Linux the entry also carries the holder's start
|
|
46
|
-
// time, so a pid another process now wears is stale too
|
|
46
|
+
// time, so a pid another process now wears is stale too, and so is a
|
|
47
|
+
// holder procfs shows as a zombie (killed, not yet reaped). Where the lock
|
|
47
48
|
// cannot be made or read for any reason but "held" (a temp directory this
|
|
48
49
|
// user cannot write), the run says so once and proceeds unlocked: the
|
|
49
50
|
// lock is a courtesy between cooperating runs, and refusing to run would
|
|
@@ -183,7 +184,7 @@ function ownLockRoot(lockDir: string): void {
|
|
|
183
184
|
|
|
184
185
|
interface Holder {
|
|
185
186
|
pid: number
|
|
186
|
-
/** The holder's start time as `
|
|
187
|
+
/** The holder's start time as `procStat` read it; null where the lock names none. */
|
|
187
188
|
start: string | null
|
|
188
189
|
/** The entry in the lock directory that names it. */
|
|
189
190
|
entry: string
|
|
@@ -223,17 +224,18 @@ async function holder(lockDir: string): Promise<Holder | 'free' | null> {
|
|
|
223
224
|
}
|
|
224
225
|
|
|
225
226
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* off Linux, where the answer costs a `ps`
|
|
229
|
-
* mounted for another pid namespace, and for a pid
|
|
227
|
+
* `/proc/<pid>/stat` from field 3 on: the state first, and at [19] field
|
|
228
|
+
* 22, the start time in clock ticks since boot, where two processes that
|
|
229
|
+
* wore one pid differ. Null off Linux, where the answer costs a `ps`
|
|
230
|
+
* spawn, under a procfs mounted for another pid namespace, and for a pid
|
|
231
|
+
* procfs does not show.
|
|
230
232
|
*/
|
|
231
|
-
function
|
|
233
|
+
function procStat(pid: number | 'self'): string[] | null {
|
|
232
234
|
if (!procfsIsOwn()) return null
|
|
233
235
|
try {
|
|
234
236
|
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
235
237
|
// Fields after the LAST ')' start at field 3; comm may hold spaces.
|
|
236
|
-
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')
|
|
238
|
+
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')
|
|
237
239
|
} catch {
|
|
238
240
|
return null
|
|
239
241
|
}
|
|
@@ -244,21 +246,24 @@ let ownId: string | undefined
|
|
|
244
246
|
/** Tells this process's takings of a lock apart, and their staging names. */
|
|
245
247
|
let takings = 0
|
|
246
248
|
function nextEntry(): string {
|
|
247
|
-
ownId ??= `h-${process.pid}-${
|
|
249
|
+
ownId ??= `h-${process.pid}-${procStat('self')?.[19] ?? 'x'}`
|
|
248
250
|
return `${ownId}-${++takings}`
|
|
249
251
|
}
|
|
250
252
|
|
|
251
253
|
/**
|
|
252
|
-
* Is the lock's holder a live run of another process?
|
|
253
|
-
* holder
|
|
254
|
-
*
|
|
254
|
+
* Is the lock's holder a live run of another process? Read on every poll:
|
|
255
|
+
* a holder killed whose parent does not reap it (a container's pid 1 that
|
|
256
|
+
* never waits) is a zombie, which signal 0 still reaches and whose start
|
|
257
|
+
* time still matches, and a wait that trusted either waited for it until
|
|
258
|
+
* the reaper came, or for good.
|
|
255
259
|
*/
|
|
256
|
-
function holderLive(h: Holder
|
|
260
|
+
function holderLive(h: Holder): boolean {
|
|
257
261
|
if (h.pid === process.pid) return placed.has(h.entry)
|
|
258
262
|
if (!alive(h.pid)) return false
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
263
|
+
const stat = procStat(h.pid)
|
|
264
|
+
if (stat === null) return true
|
|
265
|
+
if (stat[0] === 'Z' || stat[0] === 'X') return false
|
|
266
|
+
return h.start === null || stat[19] === h.start
|
|
262
267
|
}
|
|
263
268
|
|
|
264
269
|
function alive(pid: number): boolean {
|
|
@@ -326,7 +331,6 @@ export async function acquireRunLock(
|
|
|
326
331
|
const lockDir = runLockPath(workspaceRoot, opts.dir)
|
|
327
332
|
const started = Date.now()
|
|
328
333
|
let said = false
|
|
329
|
-
let lastLive: Holder | undefined
|
|
330
334
|
const release = async (): Promise<void> => {
|
|
331
335
|
const left = (heldHere.get(lockDir) ?? 1) - 1
|
|
332
336
|
if (left > 0) {
|
|
@@ -377,7 +381,7 @@ export async function acquireRunLock(
|
|
|
377
381
|
await rm(lockDir, { recursive: true, force: true })
|
|
378
382
|
continue
|
|
379
383
|
}
|
|
380
|
-
} else if (!holderLive(h
|
|
384
|
+
} else if (!holderLive(h)) {
|
|
381
385
|
if (h.entry === LEGACY_PID) await rm(lockDir, { recursive: true, force: true })
|
|
382
386
|
else await leave(lockDir, h.entry)
|
|
383
387
|
continue
|
|
@@ -391,12 +395,9 @@ export async function acquireRunLock(
|
|
|
391
395
|
)
|
|
392
396
|
return async () => {}
|
|
393
397
|
}
|
|
394
|
-
if (h !== null) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
said = true
|
|
398
|
-
opts.log(`[vx] waiting for another vx run (pid ${h.pid}) on this workspace to finish…`)
|
|
399
|
-
}
|
|
398
|
+
if (h !== null && !said && Date.now() - started >= SAY_AFTER_MS) {
|
|
399
|
+
said = true
|
|
400
|
+
opts.log(`[vx] waiting for another vx run (pid ${h.pid}) on this workspace to finish…`)
|
|
400
401
|
}
|
|
401
402
|
await Bun.sleep(POLL_MS)
|
|
402
403
|
}
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -6,7 +6,13 @@ import type { ProjectEntry } from '../workspace/index.js'
|
|
|
6
6
|
import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
|
|
7
7
|
import { realpathSync } from 'node:fs'
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
Cache,
|
|
11
|
+
type CacheLayer,
|
|
12
|
+
type CachePolicy,
|
|
13
|
+
cachesNothing,
|
|
14
|
+
stopRuntimeProbes,
|
|
15
|
+
} from '../cache/index.js'
|
|
10
16
|
import { VERSION } from '../version.js'
|
|
11
17
|
import {
|
|
12
18
|
resetSandbox,
|
|
@@ -44,6 +50,7 @@ import {
|
|
|
44
50
|
secretMask,
|
|
45
51
|
} from '../util/index.js'
|
|
46
52
|
import { keyedProjects } from './keyed-projects.js'
|
|
53
|
+
import { isDefaultBuild } from './projects.js'
|
|
47
54
|
import { prepareSandbox } from './sandbox-request.js'
|
|
48
55
|
import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
|
|
49
56
|
import { admitTasks, taintTracker } from './admission.js'
|
|
@@ -624,6 +631,7 @@ async function runOnBus(
|
|
|
624
631
|
stopRun.abort(signal)
|
|
625
632
|
},
|
|
626
633
|
done: runLeft,
|
|
634
|
+
handsTerminal: holders.size > 0,
|
|
627
635
|
// The grace, then one flush and each teardown at their own bound, and
|
|
628
636
|
// slack for the summary and the cache close.
|
|
629
637
|
boundMs:
|
|
@@ -1017,6 +1025,7 @@ async function runOnBus(
|
|
|
1017
1025
|
// is one probe of the failed-row index.
|
|
1018
1026
|
const flaky: FlakyFinding[] = []
|
|
1019
1027
|
const historyDb = prepared.localCache.dbHandle()
|
|
1028
|
+
const cacheOff = cachesNothing(policy)
|
|
1020
1029
|
const judgeFlaky = (o: TaskOutcome): void => {
|
|
1021
1030
|
const candidates = flakyCandidates([o])
|
|
1022
1031
|
if (candidates.length === 0) return
|
|
@@ -1044,6 +1053,7 @@ async function runOnBus(
|
|
|
1044
1053
|
log.taskStart?.(node)
|
|
1045
1054
|
},
|
|
1046
1055
|
onFinish: (o) => {
|
|
1056
|
+
if (cacheOff) o.cacheOff = true
|
|
1047
1057
|
taint.settled(o)
|
|
1048
1058
|
judgeFlaky(o)
|
|
1049
1059
|
log.taskComplete(o.node, o)
|
|
@@ -1346,6 +1356,8 @@ async function runOnBus(
|
|
|
1346
1356
|
// task's own facts (flaky, blocked) ride its row. Only a kept server's
|
|
1347
1357
|
// own output follows.
|
|
1348
1358
|
if (options.summaryTable === true) for (const line of formatOutcomeTable(list)) log.status(line)
|
|
1359
|
+
const above = options.beforeFooter?.(list, ok)
|
|
1360
|
+
if (above) log.status(above.replace(/\n$/, ''))
|
|
1349
1361
|
for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
|
|
1350
1362
|
|
|
1351
1363
|
// Edge case the summary already reported: the user requested a
|
|
@@ -1430,7 +1442,9 @@ async function runOnBus(
|
|
|
1430
1442
|
await summarize(ok && first.code === 0, final)
|
|
1431
1443
|
if (historyAfterWait) {
|
|
1432
1444
|
recordHistory(() => {
|
|
1433
|
-
|
|
1445
|
+
// A writer's open: another vx version may have reset the index
|
|
1446
|
+
// to its schema during the session, and a mismatch resets silently.
|
|
1447
|
+
const local = new Cache(prepared.cacheDir, undefined, prepared.workspaceRoot)
|
|
1434
1448
|
try {
|
|
1435
1449
|
local.recordRunBundle(recordsOf(final, ok && first.code === 0))
|
|
1436
1450
|
} finally {
|
|
@@ -1632,7 +1646,7 @@ async function initHint(prepared: {
|
|
|
1632
1646
|
// Single-project mode with packages the root's missing `workspaces`
|
|
1633
1647
|
// never reaches: `vx init` would find no scripts either (item 248).
|
|
1634
1648
|
const unreached = await unreachedPackages(await loadWorkspace(prepared.workspaceRoot))
|
|
1635
|
-
if (unreached.length > 0) return ` ${unreachedHint(unreached)}`
|
|
1649
|
+
if (unreached.length > 0) return ` ${unreachedHint(unreached, prepared.workspaceRoot)}`
|
|
1636
1650
|
return ' No package declares a vx.config — run `vx init` to write one per package from its package.json scripts.'
|
|
1637
1651
|
}
|
|
1638
1652
|
|
|
@@ -1649,8 +1663,11 @@ function didYouMean(
|
|
|
1649
1663
|
elsewhere: readonly string[] = [],
|
|
1650
1664
|
): string {
|
|
1651
1665
|
const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
|
|
1666
|
+
// What a bare name can select: a default `build` is no match (X-102).
|
|
1652
1667
|
const allTasks = new Set<string>()
|
|
1653
|
-
for (const p of projects.values())
|
|
1668
|
+
for (const p of projects.values()) {
|
|
1669
|
+
for (const t of tasksOf(p)) if (!isDefaultBuild(p.config.tasks![t])) allTasks.add(t)
|
|
1670
|
+
}
|
|
1654
1671
|
// A Set: two typos of the same task hint it once, not once per typo.
|
|
1655
1672
|
const hints = new Set<string>()
|
|
1656
1673
|
// A typo past two edits named nothing to pick from (M-56): with no near
|
|
@@ -1746,7 +1763,7 @@ function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
|
|
|
1746
1763
|
const out: FlakyCandidate[] = []
|
|
1747
1764
|
for (const o of outcomes) {
|
|
1748
1765
|
if (o.status !== 'success' && o.status !== 'failed') continue
|
|
1749
|
-
// "Same inputs,
|
|
1766
|
+
// "Same inputs, failed after passing" is a claim only a task with declared
|
|
1750
1767
|
// inputs can make: a task with no `cache` block keys on its config alone
|
|
1751
1768
|
// and runs every time, so one bad network day would read as a flake for
|
|
1752
1769
|
// thirty days. Groups do no work.
|