@vzn/vx 0.0.591 → 0.0.595
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/launcher.cjs +9 -2
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +18 -7
- package/src/cache/cache.ts +137 -101
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +33 -17
- package/src/cache/inputs.ts +157 -54
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/tar-stream.ts +4 -2
- package/src/cli/cache.ts +4 -16
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +56 -6
- package/src/cli/help.ts +27 -8
- package/src/cli/info.ts +4 -6
- package/src/cli/init.ts +4 -4
- package/src/cli/last.ts +49 -20
- package/src/cli/lock.ts +3 -3
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +80 -50
- package/src/cli/select.ts +44 -5
- package/src/cli/show.ts +47 -9
- package/src/cli/upgrade.ts +2 -3
- package/src/cli/watch-filter.ts +21 -7
- package/src/cli/watch-fs.ts +32 -8
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch.ts +40 -15
- package/src/cli/why.ts +4 -13
- package/src/cli/workspace-config.ts +1 -1
- package/src/config.ts +5 -5
- package/src/exec/env.ts +8 -5
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +5 -1
- package/src/exec/runner.ts +126 -47
- package/src/exec/sandbox-deny-scan.ts +2 -2
- package/src/exec/sandbox-paths.ts +11 -5
- package/src/exec/sandbox-runtime.ts +89 -23
- package/src/exec/sandbox-violations.ts +23 -9
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +14 -4
- package/src/graph/task-graph.ts +3 -2
- package/src/index.ts +9 -0
- package/src/orchestrator/affected-tasks.ts +34 -10
- package/src/orchestrator/doctor.ts +2 -1
- package/src/orchestrator/execute-task.ts +43 -10
- package/src/orchestrator/hit-restore.ts +9 -5
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +45 -49
- package/src/orchestrator/logger.ts +4 -1
- package/src/orchestrator/miss-save.ts +10 -3
- package/src/orchestrator/placement.ts +17 -3
- package/src/orchestrator/plan.ts +25 -1
- package/src/orchestrator/plugin-host.ts +41 -3
- package/src/orchestrator/plugin.ts +11 -14
- package/src/orchestrator/projects.ts +18 -9
- package/src/orchestrator/run-context.ts +2 -2
- package/src/orchestrator/run-lock.ts +14 -4
- package/src/orchestrator/run.ts +94 -16
- package/src/orchestrator/sandbox-request.ts +6 -10
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +14 -1
- package/src/util/index.ts +7 -1
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +47 -19
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-eval.ts +4 -0
- package/src/workspace/config-imports.ts +2 -2
- package/src/workspace/config-schema.ts +47 -10
- package/src/workspace/filter.ts +5 -4
- package/src/workspace/migrate-scripts.ts +23 -6
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +89 -2
- package/src/workspace/workspace.ts +121 -14
|
@@ -318,7 +318,7 @@ export function defaultLogger(
|
|
|
318
318
|
// for as long as vx holds it: `vx run dev --all` showed none of its
|
|
319
319
|
// servers' logs while it ran (C-56). So a ready server's output streams
|
|
320
320
|
// from then on, a line at a time under its id; the partial line each
|
|
321
|
-
// holds waits for its newline, a second runEnd, or `settle`.
|
|
321
|
+
// holds waits for its newline, a status line, a second runEnd, or `settle`.
|
|
322
322
|
const KEPT_LINE_CAP = 64 * 1024
|
|
323
323
|
const keptLines = new Map<string, { node: TaskNode; rest: string }>()
|
|
324
324
|
const streamKept = (node: TaskNode, chunk: string): void => {
|
|
@@ -450,6 +450,9 @@ export function defaultLogger(
|
|
|
450
450
|
writer.settle()
|
|
451
451
|
},
|
|
452
452
|
status(line) {
|
|
453
|
+
// A kept server's held partial line is older than this line: held
|
|
454
|
+
// past it, a server's last words printed below its own exit notice.
|
|
455
|
+
flushKept()
|
|
453
456
|
writer.write(`${line}\n`)
|
|
454
457
|
},
|
|
455
458
|
runStart(info) {
|
|
@@ -17,7 +17,14 @@ import {
|
|
|
17
17
|
WORKSPACE_OUTPUT_PREFIX,
|
|
18
18
|
} from '../cache/index.js'
|
|
19
19
|
import type { TaskNode } from '../graph/index.js'
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
asTrees,
|
|
22
|
+
relPosix,
|
|
23
|
+
span,
|
|
24
|
+
staticPrefix,
|
|
25
|
+
taskGlob,
|
|
26
|
+
wholeSubtreePrefixes,
|
|
27
|
+
} from '../util/index.js'
|
|
21
28
|
import type { Logger } from './logger.js'
|
|
22
29
|
import type { TaskInputComponent } from './task-hash.js'
|
|
23
30
|
|
|
@@ -272,7 +279,7 @@ function markWritten(
|
|
|
272
279
|
if (outputFiles.length > 0) {
|
|
273
280
|
a.gitFilesCache?.markOutputsChanged(
|
|
274
281
|
projectDir,
|
|
275
|
-
outputFiles.map((p) =>
|
|
282
|
+
outputFiles.map((p) => relPosix(projectDir, p)),
|
|
276
283
|
)
|
|
277
284
|
}
|
|
278
285
|
// Declared workspace outputs may have landed inside OTHER projects' dirs
|
|
@@ -281,7 +288,7 @@ function markWritten(
|
|
|
281
288
|
if (wsOutputFiles.length > 0) {
|
|
282
289
|
a.gitFilesCache?.markWorkspaceOutputsChanged(
|
|
283
290
|
a.workspaceRoot,
|
|
284
|
-
wsOutputFiles.map((f) =>
|
|
291
|
+
wsOutputFiles.map((f) => relPosix(a.workspaceRoot, f)),
|
|
285
292
|
)
|
|
286
293
|
}
|
|
287
294
|
// The workspace-wide partition (when one exists) spans this project's
|
|
@@ -281,13 +281,27 @@ export function hasPooledExecutor(executors: readonly TaskExecutor[]): boolean {
|
|
|
281
281
|
return executors.some((e) => e.capacity !== undefined)
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Each pooled executor's pool, under a name no other executor's pool
|
|
286
|
+
* shares: the scheduler counts a pool by its name, and one package declared
|
|
287
|
+
* twice (`@vzn/vx-reapi` against two clusters) names both executors alike,
|
|
288
|
+
* so their two capacities were one. A repeat takes `#2`, `#3` in placement
|
|
289
|
+
* order.
|
|
290
|
+
*/
|
|
284
291
|
export function poolOfPlacement(
|
|
285
292
|
placements: Placements,
|
|
286
293
|
): (id: string) => { name: string; capacity: number } | undefined {
|
|
294
|
+
const pools = new Map<TaskExecutor, { name: string; capacity: number }>()
|
|
295
|
+
const taken = new Set<string>()
|
|
296
|
+
for (const executor of placements.executors.values()) {
|
|
297
|
+
if (executor.capacity === undefined || pools.has(executor)) continue
|
|
298
|
+
let name = executor.name
|
|
299
|
+
for (let n = 2; taken.has(name); n++) name = `${executor.name}#${n}`
|
|
300
|
+
taken.add(name)
|
|
301
|
+
pools.set(executor, { name, capacity: executor.capacity })
|
|
302
|
+
}
|
|
287
303
|
return (id) => {
|
|
288
304
|
const executor = placements.executors.get(id)
|
|
289
|
-
return executor
|
|
290
|
-
? undefined
|
|
291
|
-
: { name: executor.name, capacity: executor.capacity }
|
|
305
|
+
return executor === undefined ? undefined : pools.get(executor)
|
|
292
306
|
}
|
|
293
307
|
}
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -13,7 +13,13 @@
|
|
|
13
13
|
|
|
14
14
|
import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
|
|
15
15
|
import { FULL_CACHE_POLICY } from '../cache/index.js'
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
idleServers,
|
|
18
|
+
isGroupTask,
|
|
19
|
+
runGraph,
|
|
20
|
+
type TaskNode,
|
|
21
|
+
type TaskOutcome,
|
|
22
|
+
} from '../graph/index.js'
|
|
17
23
|
import type { HistoryProvider } from './history.js'
|
|
18
24
|
import { computeGroupKey, computeTaskHash } from './task-hash.js'
|
|
19
25
|
import { keyUpstream } from './upstream.js'
|
|
@@ -24,6 +30,7 @@ export type CacheStatus =
|
|
|
24
30
|
| 'miss' // caching enabled but no entry — would execute
|
|
25
31
|
| 'no-cache' // task opts out of caching (no `cache` block) or --no-cache
|
|
26
32
|
| 'group' // no `exec`; just an aggregator
|
|
33
|
+
| 'not-started' // a server every dependant of which restores early: the run never spawns it
|
|
27
34
|
|
|
28
35
|
export interface PlannedTask {
|
|
29
36
|
node: TaskNode
|
|
@@ -108,6 +115,12 @@ export interface PlanArgs {
|
|
|
108
115
|
history?: HistoryProvider
|
|
109
116
|
/** Placement lookup (`placement.ts`): >1 executor declared, or a `noop` task. */
|
|
110
117
|
executorOf?: (id: string) => string | undefined
|
|
118
|
+
/**
|
|
119
|
+
* The tasks the run's short-circuit would restore ahead of their deps on
|
|
120
|
+
* a local hit (stable key, no workspace-output reach). A server whose
|
|
121
|
+
* every dependant is one of those and a local hit is never spawned.
|
|
122
|
+
*/
|
|
123
|
+
restorable?: ReadonlySet<string>
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
/**
|
|
@@ -171,6 +184,17 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
171
184
|
},
|
|
172
185
|
})
|
|
173
186
|
|
|
187
|
+
if (args.restorable !== undefined) {
|
|
188
|
+
const restoreTier = new Set(
|
|
189
|
+
[...args.restorable].filter((id) => cacheStatusById.get(id) === 'hit-local'),
|
|
190
|
+
)
|
|
191
|
+
const dependents = new Map<string, string[]>()
|
|
192
|
+
for (const node of args.nodes.values())
|
|
193
|
+
for (const dep of node.deps) dependents.set(dep, [...(dependents.get(dep) ?? []), node.id])
|
|
194
|
+
for (const id of idleServers(args.nodes, dependents, restoreTier))
|
|
195
|
+
cacheStatusById.set(id, 'not-started')
|
|
196
|
+
}
|
|
197
|
+
|
|
174
198
|
const tasks: PlannedTask[] = []
|
|
175
199
|
for (const id of args.nodes.keys()) {
|
|
176
200
|
const o = outcomes.get(id)
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// See docs/design/pipeline-2026-09.md.
|
|
9
9
|
|
|
10
|
+
import path from 'node:path'
|
|
10
11
|
import { ChainedCache, type CacheLayer } from '../cache/index.js'
|
|
11
12
|
import { localExecutor, type TaskExecutor } from '../exec/index.js'
|
|
12
13
|
import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
|
|
@@ -106,6 +107,25 @@ export function hasHook(
|
|
|
106
107
|
return false
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/**
|
|
111
|
+
* An in-place stage reads nothing a hook returns: Vite's `config` hook
|
|
112
|
+
* returns a partial config, and one written that way here loaded, changed
|
|
113
|
+
* nothing and said nothing. Returning the object it was handed is harmless.
|
|
114
|
+
*/
|
|
115
|
+
function assertEditedInPlace(
|
|
116
|
+
plugin: VxPlugin,
|
|
117
|
+
hook: 'config' | 'project' | 'graph',
|
|
118
|
+
returned: unknown,
|
|
119
|
+
edited: unknown,
|
|
120
|
+
what: string,
|
|
121
|
+
): void {
|
|
122
|
+
if (returned === undefined || returned === edited) return
|
|
123
|
+
throw new UserError(
|
|
124
|
+
`plugin '${plugin.name}' failed in ${hook}: returned ${describeValue(returned)}, which core ` +
|
|
125
|
+
`ignores — edit ${what} in place`,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
109
129
|
/** `config` stage: every plugin edits the workspace config in place. */
|
|
110
130
|
export async function applyConfigHooks(
|
|
111
131
|
plugins: readonly VxPlugin[],
|
|
@@ -116,7 +136,8 @@ export async function applyConfigHooks(
|
|
|
116
136
|
): Promise<void> {
|
|
117
137
|
for (const plugin of plugins) {
|
|
118
138
|
if (plugin.config === undefined) continue
|
|
119
|
-
await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
|
|
139
|
+
const returned = await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
|
|
140
|
+
assertEditedInPlace(plugin, 'config', returned, workspace, 'the workspace config')
|
|
120
141
|
afterEach?.(plugin)
|
|
121
142
|
}
|
|
122
143
|
}
|
|
@@ -157,7 +178,8 @@ export async function applyProjectHooks(
|
|
|
157
178
|
): Promise<void> {
|
|
158
179
|
for (const plugin of plugins) {
|
|
159
180
|
if (plugin.project === undefined) continue
|
|
160
|
-
await safe(plugin, 'project', () => plugin.project!(config, ctx))
|
|
181
|
+
const returned = await safe(plugin, 'project', () => plugin.project!(config, ctx))
|
|
182
|
+
assertEditedInPlace(plugin, 'project', returned, config, "the project's config")
|
|
161
183
|
afterEach?.(plugin)
|
|
162
184
|
}
|
|
163
185
|
}
|
|
@@ -183,7 +205,8 @@ export async function applyGraphHooks(
|
|
|
183
205
|
let last: VxPlugin | undefined
|
|
184
206
|
for (const plugin of plugins) {
|
|
185
207
|
if (plugin.graph === undefined) continue
|
|
186
|
-
await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
|
|
208
|
+
const returned = await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
|
|
209
|
+
assertEditedInPlace(plugin, 'graph', returned, nodes, 'the task graph')
|
|
187
210
|
await safe(plugin, 'graph', () => checkNodeShapes(nodes))
|
|
188
211
|
afterEach?.(plugin)
|
|
189
212
|
last = plugin
|
|
@@ -207,6 +230,21 @@ function checkNodeShapes(nodes: Map<string, TaskNode>): void {
|
|
|
207
230
|
if (!Array.isArray(deps)) {
|
|
208
231
|
throw new Error(`${key}'s deps is ${what(deps)}, not an array of task ids`)
|
|
209
232
|
}
|
|
233
|
+
// A node a hook adds is read as it wrote it: one with no `projectDir`
|
|
234
|
+
// ran as `internal error in a#x: TypeError: The "path" property must
|
|
235
|
+
// be of type string`, and a relative one keyed and ran against
|
|
236
|
+
// whatever directory vx was started in.
|
|
237
|
+
const fields = node as { projectName?: unknown; taskName?: unknown; projectDir?: unknown }
|
|
238
|
+
const shown = (v: unknown): string => (typeof v === 'string' ? JSON.stringify(v) : what(v))
|
|
239
|
+
for (const field of ['projectName', 'taskName'] as const) {
|
|
240
|
+
if (typeof fields[field] !== 'string' || fields[field] === '') {
|
|
241
|
+
throw new Error(`${key}'s ${field} is ${shown(fields[field])}, not a name`)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const dir = fields.projectDir
|
|
245
|
+
if (typeof dir !== 'string' || !path.isAbsolute(dir)) {
|
|
246
|
+
throw new Error(`${key}'s projectDir is ${shown(dir)}, not an absolute path`)
|
|
247
|
+
}
|
|
210
248
|
}
|
|
211
249
|
}
|
|
212
250
|
|
|
@@ -227,7 +227,14 @@ interface BaseContext {
|
|
|
227
227
|
warn(message: string): void
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
|
|
230
|
+
/** What `setup` receives: the run's lifecycle, observe-only. */
|
|
231
|
+
export interface PluginSetupContext extends BaseContext {
|
|
232
|
+
/**
|
|
233
|
+
* Subscribe a lifecycle handler; it leaves with the run. A throw or a
|
|
234
|
+
* rejection warns once and switches the plugin's handlers off for the run.
|
|
235
|
+
*/
|
|
236
|
+
on<K extends PluginHookName>(hook: K, handler: PluginHookHandlers[K]): void
|
|
237
|
+
}
|
|
231
238
|
|
|
232
239
|
/** One CLI verb contributed by a plugin. */
|
|
233
240
|
export interface PluginCommand {
|
|
@@ -357,23 +364,12 @@ export interface ExecutorContext extends BaseContext {
|
|
|
357
364
|
readonly concurrency: number
|
|
358
365
|
}
|
|
359
366
|
|
|
360
|
-
export interface PluginContext {
|
|
361
|
-
/** Where the workspace lives on disk. */
|
|
362
|
-
readonly workspaceRoot: string
|
|
363
|
-
/** Where vx's cache lives — read-only as far as the plugin is concerned. */
|
|
364
|
-
readonly cacheDir: string
|
|
365
|
-
/** Funnel warnings into the run:status channel, as every hook's context does. */
|
|
366
|
-
warn(message: string): void
|
|
367
|
+
export interface PluginContext extends PluginSetupContext {
|
|
367
368
|
/**
|
|
368
369
|
* The run event bus. A plugin can subscribe directly if its needs exceed
|
|
369
370
|
* the hooks; the subscription ends with the run, as a hook's does.
|
|
370
371
|
*/
|
|
371
372
|
readonly bus: EventBus
|
|
372
|
-
/**
|
|
373
|
-
* Convenience: register a typed handler keyed off `RunEvent.kind`.
|
|
374
|
-
* Multiple hooks can chain via repeated calls.
|
|
375
|
-
*/
|
|
376
|
-
on<K extends PluginHookName>(hook: K, handler: PluginHookHandlers[K]): void
|
|
377
373
|
}
|
|
378
374
|
|
|
379
375
|
export type PluginHookName =
|
|
@@ -444,7 +440,8 @@ function pluginPackageName(dir: string): string {
|
|
|
444
440
|
for (let d = dir; ;) {
|
|
445
441
|
let text: string | undefined
|
|
446
442
|
try {
|
|
447
|
-
|
|
443
|
+
// A byte-order mark is stripped, as npm, Node and discovery strip it.
|
|
444
|
+
text = readFileSync(path.join(d, 'package.json'), 'utf8').replace(/^\uFEFF/, '')
|
|
448
445
|
} catch {
|
|
449
446
|
/* not here; look one level up */
|
|
450
447
|
}
|
|
@@ -43,16 +43,25 @@ export async function loadWorkspacePlugins(
|
|
|
43
43
|
workspaceRoot: string,
|
|
44
44
|
warn: (message: string) => void,
|
|
45
45
|
): Promise<{ workspaceConfig: WorkspaceConfig | null; plugins: readonly VxPlugin[] }> {
|
|
46
|
-
const
|
|
47
|
-
const plugins = (
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
const loaded = await loadWorkspaceConfig(workspaceRoot)
|
|
47
|
+
const plugins = (loaded?.plugins ?? []) as readonly VxPlugin[]
|
|
48
|
+
if (loaded === null || !hasHook(plugins, 'config')) return { workspaceConfig: loaded, plugins }
|
|
49
|
+
// Bun keeps one module per specifier, so the file's export is one object
|
|
50
|
+
// per process: the CLI's selection pass, the run and each `vx watch`
|
|
51
|
+
// cycle handed the hooks what the last load's hooks had edited
|
|
52
|
+
// (`concurrency *= 2` doubled per load). The hooks edit a copy; the file
|
|
53
|
+
// holds data and plugins, and validation refused anything else.
|
|
54
|
+
const { plugins: list, ...data } = loaded
|
|
55
|
+
const workspaceConfig: WorkspaceConfig = {
|
|
56
|
+
...structuredClone(data),
|
|
57
|
+
...(list !== undefined ? { plugins: [...list] } : {}),
|
|
55
58
|
}
|
|
59
|
+
// Re-validated after each plugin, as the `project` stage is: an edit
|
|
60
|
+
// the loader would refuse from a user (`concurrency: -3`) hung the run,
|
|
61
|
+
// and `cacheDir: 42` reached `path.resolve` as a TypeError.
|
|
62
|
+
await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn }, (plugin) =>
|
|
63
|
+
validateWorkspace(workspaceConfig, `vx.workspace (after plugin '${plugin.name}')`),
|
|
64
|
+
)
|
|
56
65
|
return { workspaceConfig, plugins }
|
|
57
66
|
}
|
|
58
67
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import fs from 'node:fs'
|
|
14
14
|
import os from 'node:os'
|
|
15
15
|
import path from 'node:path'
|
|
16
|
-
import { executablePath, ulid, xxh3hex } from '../util/index.js'
|
|
16
|
+
import { executablePath, relPosix, ulid, xxh3hex } from '../util/index.js'
|
|
17
17
|
|
|
18
18
|
export interface GitContext {
|
|
19
19
|
/** `git rev-parse HEAD`, or null outside a repo / on failure. */
|
|
@@ -410,7 +410,7 @@ function workTreePath(root: string): string | undefined {
|
|
|
410
410
|
const abs = path.resolve(root)
|
|
411
411
|
for (let dir = abs; ;) {
|
|
412
412
|
if (fs.existsSync(path.join(dir, '.git'))) {
|
|
413
|
-
return
|
|
413
|
+
return relPosix(dir, abs) || '.'
|
|
414
414
|
}
|
|
415
415
|
const up = path.dirname(dir)
|
|
416
416
|
if (up === dir) return undefined
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
// A holder's pid can come back: the temp directory outlives a container
|
|
42
42
|
// restart, and the restarted container's vx got the dead run's pid (1) and
|
|
43
43
|
// waited for itself forever (nx#36473 reproduced on vx, 2026-09-24). So a
|
|
44
|
-
// lock naming OUR pid is stale
|
|
45
|
-
// `
|
|
44
|
+
// lock naming OUR pid is stale unless this process placed that entry and
|
|
45
|
+
// has not left it (`placed`) — and on Linux the entry also carries the holder's start
|
|
46
46
|
// time, so a pid another process now wears is stale too. Where the lock
|
|
47
47
|
// cannot be made or read for any reason but "held" (a temp directory this
|
|
48
48
|
// user cannot write), the run says so once and proceeds unlocked: the
|
|
@@ -71,6 +71,12 @@ import { isTmpdirRefusal, procfsIsOwn, TMPDIR_HINT, xxh3hex } from '../util/inde
|
|
|
71
71
|
const heldHere = new Map<string, number>()
|
|
72
72
|
/** The entry this process's taking of each held lock wrote. */
|
|
73
73
|
const takers = new Map<string, string>()
|
|
74
|
+
/**
|
|
75
|
+
* Every entry this process has placed and not yet left, listed before its
|
|
76
|
+
* rename: a concurrent taking here reads it before `heldHere` names it, and
|
|
77
|
+
* must wait for it, not reclaim it as a dead run of this pid.
|
|
78
|
+
*/
|
|
79
|
+
const placed = new Set<string>()
|
|
74
80
|
|
|
75
81
|
/**
|
|
76
82
|
* A signal exit is `process.exit` (signals.ts): no finally runs and nothing
|
|
@@ -248,7 +254,7 @@ function nextEntry(): string {
|
|
|
248
254
|
* procfs once per holder, not once per poll.
|
|
249
255
|
*/
|
|
250
256
|
function holderLive(h: Holder, sameAsLast: boolean): boolean {
|
|
251
|
-
if (h.pid === process.pid) return
|
|
257
|
+
if (h.pid === process.pid) return placed.has(h.entry)
|
|
252
258
|
if (!alive(h.pid)) return false
|
|
253
259
|
if (h.start === null || sameAsLast) return true
|
|
254
260
|
const now = startTime(h.pid)
|
|
@@ -278,11 +284,13 @@ async function place(lockDir: string): Promise<string | null> {
|
|
|
278
284
|
// directory a killed pid 1 left, and refuse every run after it.
|
|
279
285
|
const staging = `${lockDir}.${entry}.${Math.random().toString(36).slice(2)}`
|
|
280
286
|
await mkdir(staging)
|
|
287
|
+
placed.add(entry)
|
|
281
288
|
try {
|
|
282
289
|
await writeFile(path.join(staging, entry), '')
|
|
283
290
|
await rename(staging, lockDir)
|
|
284
291
|
return entry
|
|
285
292
|
} catch (err) {
|
|
293
|
+
placed.delete(entry)
|
|
286
294
|
await rm(staging, { recursive: true, force: true })
|
|
287
295
|
const code = (err as NodeJS.ErrnoException).code
|
|
288
296
|
if (code === 'ENOTEMPTY' || code === 'EEXIST') return null
|
|
@@ -327,6 +335,8 @@ export async function acquireRunLock(
|
|
|
327
335
|
}
|
|
328
336
|
heldHere.delete(lockDir)
|
|
329
337
|
const taker = takers.get(lockDir)
|
|
338
|
+
// Leaving: a taking here from now on may reclaim it, as before its unlink.
|
|
339
|
+
if (taker !== undefined) placed.delete(taker)
|
|
330
340
|
// The unlink of this taking's own entry is the proof no later run
|
|
331
341
|
// reclaimed the lock: had one, the entry is gone and nothing else is
|
|
332
342
|
// touched. A run that joined another's taking in this process leaves
|
|
@@ -388,6 +398,6 @@ export async function acquireRunLock(
|
|
|
388
398
|
opts.log(`[vx] waiting for another vx run (pid ${h.pid}) on this workspace to finish…`)
|
|
389
399
|
}
|
|
390
400
|
}
|
|
391
|
-
await
|
|
401
|
+
await Bun.sleep(POLL_MS)
|
|
392
402
|
}
|
|
393
403
|
}
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
6
6
|
import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
|
|
7
|
+
import { realpathSync } from 'node:fs'
|
|
7
8
|
import path from 'node:path'
|
|
8
9
|
import { Cache, type CacheLayer, type CachePolicy, stopRuntimeProbes } from '../cache/index.js'
|
|
9
10
|
import { VERSION } from '../version.js'
|
|
@@ -18,6 +19,7 @@ import { resolveDownloadModes } from './download-policy.js'
|
|
|
18
19
|
import type { TaskExecutor } from '../exec/index.js'
|
|
19
20
|
import {
|
|
20
21
|
deadServerBehind,
|
|
22
|
+
declaredTask,
|
|
21
23
|
isGroupTask,
|
|
22
24
|
markSurfacedDeps,
|
|
23
25
|
runGraph,
|
|
@@ -85,8 +87,12 @@ import {
|
|
|
85
87
|
detectCi,
|
|
86
88
|
} from './run-context.js'
|
|
87
89
|
import { startRemotePrefetch } from './remote-prefetch.js'
|
|
88
|
-
import {
|
|
89
|
-
|
|
90
|
+
import {
|
|
91
|
+
restoreTierExclusions,
|
|
92
|
+
startLocalShortCircuit,
|
|
93
|
+
type ShortCircuit,
|
|
94
|
+
} from './local-shortcircuit.js'
|
|
95
|
+
import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
|
|
90
96
|
|
|
91
97
|
import { assembleRunRecords } from './run-records.js'
|
|
92
98
|
import { hasEnded, selectKeepAlive, shutdownPersistent } from './persistent.js'
|
|
@@ -366,8 +372,10 @@ async function runOnBus(
|
|
|
366
372
|
// its tasks escape the schedule, the concurrency budget and this task's
|
|
367
373
|
// cache key. The markers `taskEnv` sets on every child (exec/env.ts)
|
|
368
374
|
// name the task; the check is on the root so both shapes are caught.
|
|
375
|
+
// Compared canonical: a root reached through a symlink (`run({ cwd })`,
|
|
376
|
+
// macOS's /var/folders) and the inner vx's realpath'd cwd are one tree.
|
|
369
377
|
const outerRoot = process.env[VX_RUN_WORKSPACE_ENV]
|
|
370
|
-
if (outerRoot !== undefined &&
|
|
378
|
+
if (outerRoot !== undefined && canonical(outerRoot) === canonical(prepared.workspaceRoot)) {
|
|
371
379
|
await teardown()
|
|
372
380
|
prepared.cache.close()
|
|
373
381
|
throw new UserError(
|
|
@@ -395,7 +403,8 @@ async function runOnBus(
|
|
|
395
403
|
const reachedNone =
|
|
396
404
|
options.affected !== undefined &&
|
|
397
405
|
[...prepared.projects.values()].some(
|
|
398
|
-
(p) =>
|
|
406
|
+
(p) =>
|
|
407
|
+
inScope.has(p.name) && options.tasks.some((t) => declaredTask(p.config, t) !== undefined),
|
|
399
408
|
)
|
|
400
409
|
log.status(
|
|
401
410
|
reachedNone
|
|
@@ -427,7 +436,7 @@ async function runOnBus(
|
|
|
427
436
|
let telemetry: TelemetryHandle | undefined
|
|
428
437
|
try {
|
|
429
438
|
disposePlugins = await installPlugins({
|
|
430
|
-
plugins: prepared.plugins
|
|
439
|
+
plugins: prepared.plugins,
|
|
431
440
|
bus,
|
|
432
441
|
workspaceRoot: prepared.workspaceRoot,
|
|
433
442
|
cacheDir: prepared.cacheDir,
|
|
@@ -930,6 +939,9 @@ async function runOnBus(
|
|
|
930
939
|
const explainMiss =
|
|
931
940
|
telemetry === undefined ? undefined : createMissExplainer(prepared.localCache.dbHandle())
|
|
932
941
|
const lateProbes = probesAfterWrites(nodes, workspaceRoot)
|
|
942
|
+
// The scheduler's fail-fast stop, as the retry loop of a task already
|
|
943
|
+
// in flight sees it.
|
|
944
|
+
const failFast = new AbortController()
|
|
933
945
|
const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
|
|
934
946
|
const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
|
|
935
947
|
const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
|
|
@@ -977,6 +989,7 @@ async function runOnBus(
|
|
|
977
989
|
deferSave: saveLane.defer,
|
|
978
990
|
deferredSaves,
|
|
979
991
|
stopSignal: stopRun.signal,
|
|
992
|
+
failFast: failFast.signal,
|
|
980
993
|
}
|
|
981
994
|
}
|
|
982
995
|
|
|
@@ -1025,6 +1038,7 @@ async function runOnBus(
|
|
|
1025
1038
|
...(holders.size > 0 ? { exclusive: holders } : {}),
|
|
1026
1039
|
...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
|
|
1027
1040
|
serverDied,
|
|
1041
|
+
onFailFast: () => failFast.abort(),
|
|
1028
1042
|
signal: stopRun.signal,
|
|
1029
1043
|
onStart: (node) => {
|
|
1030
1044
|
log.taskStart?.(node)
|
|
@@ -1076,8 +1090,6 @@ async function runOnBus(
|
|
|
1076
1090
|
).filter((c) => endedBeforeStop?.has(persistentRegistry.get(c.id)!) ?? true)
|
|
1077
1091
|
|
|
1078
1092
|
mark('run graph')
|
|
1079
|
-
// Clear the status line for good before the summary prints.
|
|
1080
|
-
log.runEnd?.()
|
|
1081
1093
|
|
|
1082
1094
|
// A server that died on its own before the stop failed, whatever its
|
|
1083
1095
|
// outcome said when it became ready: the footer counted it a success
|
|
@@ -1087,8 +1099,11 @@ async function runOnBus(
|
|
|
1087
1099
|
const failServer = (id: string, code: number | string): void => {
|
|
1088
1100
|
const o = outcomes.get(id)
|
|
1089
1101
|
if (o === undefined) return
|
|
1090
|
-
|
|
1091
|
-
|
|
1102
|
+
// In place: the renderer holds this outcome (events carry live refs)
|
|
1103
|
+
// and closes the server's output block from it at runEnd, where a
|
|
1104
|
+
// copy left it reading `running` under its exit line (WD-10).
|
|
1105
|
+
o.status = 'failed'
|
|
1106
|
+
o.exitCode = typeof code === 'number' ? code : signalExitCode(code)
|
|
1092
1107
|
}
|
|
1093
1108
|
for (const c of crashedPersistent) failServer(c.id, c.code)
|
|
1094
1109
|
keepAlive.nodes.forEach((n, i) => {
|
|
@@ -1097,6 +1112,8 @@ async function runOnBus(
|
|
|
1097
1112
|
if (endedBeforeStop !== undefined && !endedBeforeStop.has(child)) return
|
|
1098
1113
|
failServer(n.id, child.exitCode ?? child.signalCode ?? 'unknown')
|
|
1099
1114
|
})
|
|
1115
|
+
// Clear the status line for good before the summary prints.
|
|
1116
|
+
log.runEnd?.()
|
|
1100
1117
|
const list = [...outcomes.values()]
|
|
1101
1118
|
const ok = list.every((o) => isPassStatus(o.status))
|
|
1102
1119
|
|
|
@@ -1371,11 +1388,24 @@ async function runOnBus(
|
|
|
1371
1388
|
}
|
|
1372
1389
|
}
|
|
1373
1390
|
if (keepAlive.children.length > 0) {
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1391
|
+
// A server kept only as a dependency that exits 0 is a daemon that
|
|
1392
|
+
// forked and returned (schema.md), not the end of the session: a
|
|
1393
|
+
// `db: 'docker compose up -d'` under `app#dev` stopped the dev server
|
|
1394
|
+
// right after the summary (WD-3). It ends the wait only as the last.
|
|
1395
|
+
const first = await new Promise<{ code: number; i: number }>((resolve) => {
|
|
1396
|
+
let left = keepAlive.children.length
|
|
1397
|
+
keepAlive.children.forEach((c, i) => {
|
|
1398
|
+
void c.exited.then((code) => {
|
|
1399
|
+
left--
|
|
1400
|
+
const n = keepAlive.nodes[i]!
|
|
1401
|
+
if (code !== 0 || n.requested || n.surfaced === true || left === 0) resolve({ code, i })
|
|
1402
|
+
})
|
|
1403
|
+
})
|
|
1404
|
+
})
|
|
1377
1405
|
const node = keepAlive.nodes[first.i]!
|
|
1378
|
-
|
|
1406
|
+
// Only the ones still up are stopped: a kept server already dead was
|
|
1407
|
+
// counted as one (WD-11).
|
|
1408
|
+
const others = keepAlive.children.filter((c, j) => j !== first.i && !hasEnded(c)).length
|
|
1379
1409
|
// Not when the run was stopped: the server ended because the user
|
|
1380
1410
|
// stopped it, and "exited with code 130" read as a crash after every
|
|
1381
1411
|
// Ctrl-C once a stop let run() finish its own path (item 852).
|
|
@@ -1387,7 +1417,11 @@ async function runOnBus(
|
|
|
1387
1417
|
: ''),
|
|
1388
1418
|
)
|
|
1389
1419
|
}
|
|
1390
|
-
|
|
1420
|
+
// Under a stop the abort is already taking them down with the stop's
|
|
1421
|
+
// signal; a second SIGTERM in its grace cut short the graceful
|
|
1422
|
+
// shutdown of a server that reads it as "quit now" (WD-16).
|
|
1423
|
+
if (stopRun.signal.aborted) await aborting?.catch(() => {})
|
|
1424
|
+
else await terminateChildren(() => keepAlive.children)
|
|
1391
1425
|
// The server that ended the session on its own, not cleanly, failed:
|
|
1392
1426
|
// the rewritten summary said `ok: false` over every task `success`
|
|
1393
1427
|
// and `failed: 0`, and so did the outcomes `--report` renders (C-53).
|
|
@@ -1501,12 +1535,13 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1501
1535
|
// Its own mark: a dry run's plan (every task's hash, the cache lookups,
|
|
1502
1536
|
// the history p50s) was booked under `close`, the next mark, and read
|
|
1503
1537
|
// as 105 ms of closing a cache at 1,000 projects (item 601).
|
|
1538
|
+
const policy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
|
|
1504
1539
|
const planned = await plan({
|
|
1505
1540
|
nodes: prepared.nodes,
|
|
1506
1541
|
workspaceRoot: prepared.workspaceRoot,
|
|
1507
1542
|
workspaceFingerprint: prepared.workspaceFingerprint,
|
|
1508
1543
|
cache: prepared.cache,
|
|
1509
|
-
cachePolicy:
|
|
1544
|
+
cachePolicy: policy,
|
|
1510
1545
|
forwardArgs: options.forwardArgs,
|
|
1511
1546
|
nestedDirsByProject: prepared.nestedDirsByProject,
|
|
1512
1547
|
gitFilesCache: prepared.gitFilesCache,
|
|
@@ -1523,6 +1558,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1523
1558
|
// the same plugin-factory call `prepareRun` already makes for the
|
|
1524
1559
|
// cache capability, so plan mode gains no new class of side effect.
|
|
1525
1560
|
...(await planExecutorOf(prepared, log, options.download ?? 'all')),
|
|
1561
|
+
...(await planRestorable(prepared, policy, options.forwardArgs)),
|
|
1526
1562
|
})
|
|
1527
1563
|
mark('plan')
|
|
1528
1564
|
return planned
|
|
@@ -1539,6 +1575,39 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1539
1575
|
}
|
|
1540
1576
|
}
|
|
1541
1577
|
|
|
1578
|
+
/**
|
|
1579
|
+
* What the run's short-circuit would restore ahead of its deps, so the plan
|
|
1580
|
+
* can tell a server nobody needs from one that starts: `--dry` called a
|
|
1581
|
+
* server every dependant of which was a local hit `would exec`, and the run
|
|
1582
|
+
* never spawned it. The run's own gates (`shouldShortCircuit`, stable keys,
|
|
1583
|
+
* workspace-output reach), asked only when a server could idle.
|
|
1584
|
+
*/
|
|
1585
|
+
async function planRestorable(
|
|
1586
|
+
prepared: PreparedRun,
|
|
1587
|
+
policy: CachePolicy,
|
|
1588
|
+
forwardArgs: readonly string[] | undefined,
|
|
1589
|
+
): Promise<{ restorable?: Set<string> }> {
|
|
1590
|
+
const { nodes } = prepared
|
|
1591
|
+
const serves = [...nodes.values()].some(
|
|
1592
|
+
(n) => n.config.exec?.persistent !== undefined && !n.requested && n.surfaced !== true,
|
|
1593
|
+
)
|
|
1594
|
+
if (!serves || !shouldShortCircuit(nodes, policy, prepared.cache)) return {}
|
|
1595
|
+
const keptOut = restoreTierExclusions(nodes, prepared.workspaceRoot)
|
|
1596
|
+
const stable = await deriveStableKeys({
|
|
1597
|
+
nodes,
|
|
1598
|
+
cache: prepared.cache,
|
|
1599
|
+
workspaceRoot: prepared.workspaceRoot,
|
|
1600
|
+
workspaceFingerprint: prepared.workspaceFingerprint,
|
|
1601
|
+
forwardArgs,
|
|
1602
|
+
nestedDirsByProject: prepared.nestedDirsByProject,
|
|
1603
|
+
gitFilesCache: prepared.gitFilesCache,
|
|
1604
|
+
hashCache: prepared.hashCache,
|
|
1605
|
+
})
|
|
1606
|
+
return {
|
|
1607
|
+
restorable: new Set(stable.map((k) => k.node.id).filter((id) => !keptOut.has(id))),
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1542
1611
|
/** The names a project outside the run's selection declares, and how to reach them. */
|
|
1543
1612
|
function elsewhereHint(names: readonly string[]): string {
|
|
1544
1613
|
if (names.length === 0) return ''
|
|
@@ -1652,7 +1721,7 @@ export function nxProjectTarget(
|
|
|
1652
1721
|
if (colon <= 0 || spec.includes('#')) return undefined
|
|
1653
1722
|
const [typed, task] = [spec.slice(0, colon), spec.slice(colon + 1)]
|
|
1654
1723
|
const project = projectNamed(typed, projects)
|
|
1655
|
-
return project === undefined || projects.get(project)?.config
|
|
1724
|
+
return project === undefined || declaredTask(projects.get(project)?.config, task) === undefined
|
|
1656
1725
|
? undefined
|
|
1657
1726
|
: `${project}#${task}`
|
|
1658
1727
|
}
|
|
@@ -1692,3 +1761,12 @@ function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
|
|
|
1692
1761
|
}
|
|
1693
1762
|
return out
|
|
1694
1763
|
}
|
|
1764
|
+
|
|
1765
|
+
/** `dir` with its links resolved; as written when it is gone (a stale marker). */
|
|
1766
|
+
function canonical(dir: string): string {
|
|
1767
|
+
try {
|
|
1768
|
+
return realpathSync(dir)
|
|
1769
|
+
} catch {
|
|
1770
|
+
return path.resolve(dir)
|
|
1771
|
+
}
|
|
1772
|
+
}
|