@vzn/vx 0.0.644 → 0.0.646
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +22 -48
- package/package.json +5 -5
- package/schemas/init.json +5 -0
- package/schemas/last.json +15 -0
- package/skills/vx/SKILL.md +1 -1
- package/src/bin.ts +1 -1
- package/src/cache/archive.ts +12 -76
- package/src/cache/cache.ts +72 -42
- package/src/cache/git-inputs.ts +55 -10
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +61 -11
- package/src/cache/layer.ts +30 -8
- package/src/cache/layered-cache.ts +138 -44
- package/src/cache/output-index.ts +25 -11
- package/src/cache/plugin-cache.ts +9 -14
- package/src/cache/remote-lane.ts +60 -22
- package/src/cache/run-history.ts +13 -6
- package/src/cache/schema.ts +19 -2
- package/src/cache/scoped-cache.ts +3 -80
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/help.ts +8 -10
- package/src/cli/index.ts +0 -12
- package/src/cli/init.ts +159 -25
- package/src/cli/last.ts +4 -2
- package/src/cli/plan-format.ts +6 -2
- package/src/cli/plugin-commands.ts +98 -6
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +2 -8
- package/src/cli/select.ts +5 -3
- package/src/cli/upgrade.ts +5 -6
- package/src/config.ts +2 -4
- package/src/exec/executor.ts +112 -9
- package/src/exec/index.ts +2 -0
- package/src/exec/proc-sample.ts +67 -5
- package/src/exec/runner.ts +4 -0
- package/src/exec/sandbox-runtime.ts +11 -4
- package/src/graph/scheduler.ts +59 -8
- package/src/history/history.ts +119 -7
- package/src/history/metrics.ts +53 -16
- package/src/index.ts +16 -1
- package/src/orchestrator/affected-tasks.ts +0 -18
- package/src/orchestrator/execute-args.ts +3 -0
- package/src/orchestrator/execute-task.ts +28 -7
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +11 -4
- package/src/orchestrator/index.ts +18 -1
- package/src/orchestrator/local-floor.ts +104 -0
- package/src/orchestrator/local-shortcircuit.ts +26 -7
- package/src/orchestrator/miss-save.ts +10 -4
- package/src/orchestrator/options.ts +0 -8
- package/src/orchestrator/persistent-task.ts +14 -0
- package/src/orchestrator/placement.ts +84 -9
- package/src/orchestrator/plan.ts +7 -1
- package/src/orchestrator/plugin-host.ts +22 -7
- package/src/orchestrator/plugin.ts +48 -7
- package/src/orchestrator/prepare.ts +14 -49
- package/src/orchestrator/run-context.ts +39 -1
- package/src/orchestrator/run-keys.ts +164 -0
- package/src/orchestrator/run-records.ts +6 -0
- package/src/orchestrator/run.ts +95 -6
- package/src/orchestrator/sandbox-request.ts +9 -3
- package/src/orchestrator/task-artifacts.ts +142 -0
- package/src/orchestrator/task-hash.ts +12 -4
- package/src/orchestrator/task-process.ts +16 -0
- package/src/orchestrator/telemetry-host.ts +2 -1
- package/src/orchestrator/telemetry.ts +60 -17
- package/src/report/forecast.ts +6 -27
- package/src/report/framed-output.ts +41 -5
- package/src/report/logger.ts +60 -20
- package/src/report/status-line.ts +38 -18
- package/src/report/summary.ts +75 -9
- package/src/settings.ts +8 -7
- package/src/util/colors.ts +16 -1
- package/src/util/index.ts +1 -0
- package/src/util/paths.ts +30 -0
- package/src/util/ui.ts +4 -1
- package/src/workspace/affected.ts +0 -39
- package/src/workspace/config-bytecode.ts +276 -0
- package/src/workspace/index.ts +0 -2
- package/src/workspace/migration.ts +4 -32
- package/src/workspace/project-loader.ts +92 -14
- package/src/cli/foreign-flags.ts +0 -727
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
// through here (enforced by tests/module-boundaries.test.ts).
|
|
3
3
|
|
|
4
4
|
export { run, planRun, nxProjectTarget, projectNamed } from './run.js'
|
|
5
|
-
export {
|
|
5
|
+
export {
|
|
6
|
+
namesRemote,
|
|
7
|
+
openRemotes,
|
|
8
|
+
prepareRun,
|
|
9
|
+
workspaceCachePolicy,
|
|
10
|
+
type PreparedRun,
|
|
11
|
+
} from './prepare.js'
|
|
6
12
|
export { affectedTaskProjects } from './affected-tasks.js'
|
|
7
13
|
export { applyGraphStage } from './graph-stage.js'
|
|
8
14
|
export type { AffectedReason } from './affected-tasks.js'
|
|
@@ -37,6 +43,14 @@ export { forwardedSignal } from './signals.js'
|
|
|
37
43
|
// `vx cache clean` waits for a run on the workspace before it evicts.
|
|
38
44
|
export { acquireRunLock } from './run-lock.js'
|
|
39
45
|
export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
|
|
46
|
+
export {
|
|
47
|
+
cacheFacts,
|
|
48
|
+
taskArtifacts,
|
|
49
|
+
type ArtifactRun,
|
|
50
|
+
type ArtifactTask,
|
|
51
|
+
type CacheFacts,
|
|
52
|
+
type TaskArtifacts,
|
|
53
|
+
} from './task-artifacts.js'
|
|
40
54
|
export {
|
|
41
55
|
installPlugins,
|
|
42
56
|
type CacheContext,
|
|
@@ -120,6 +134,7 @@ export {
|
|
|
120
134
|
currentBranch,
|
|
121
135
|
detectCi,
|
|
122
136
|
localBranchScope,
|
|
137
|
+
cacheRefs,
|
|
123
138
|
resolveCacheScope,
|
|
124
139
|
} from './run-context.js'
|
|
125
140
|
export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './run-context.js'
|
|
@@ -133,3 +148,5 @@ export {
|
|
|
133
148
|
export { type RunStep, runSteps } from './run-steps.js'
|
|
134
149
|
export { resolveRunId, shortRunId } from './run-id.js'
|
|
135
150
|
export { collectInfo, type CollectInfoOptions, type InfoFacts } from './doctor.js'
|
|
151
|
+
export { localFloor, type LocalFloor } from './local-floor.js'
|
|
152
|
+
export { sandboxRequestFor, type SandboxRequest } from './sandbox-request.js'
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Core's local floor for a plugin that runs tasks itself: `@vzn/vx-dte`'s
|
|
2
|
+
// orchestrator for its own share, and its agents, which run tasks another
|
|
3
|
+
// machine placed. A run arms the sandbox runtime before it builds a
|
|
4
|
+
// sandboxed request; an agent is no run, so it arms here. It starts a
|
|
5
|
+
// request's servers (`ExecuteRequest.servers`) once each, before the
|
|
6
|
+
// task, and stops them when it closes.
|
|
7
|
+
|
|
8
|
+
import type { ExecConfig } from '../config.js'
|
|
9
|
+
import {
|
|
10
|
+
localExecutor,
|
|
11
|
+
resetSandbox,
|
|
12
|
+
runPersistent,
|
|
13
|
+
type ExecuteRequest,
|
|
14
|
+
type ExecuteResult,
|
|
15
|
+
type ServerSpec,
|
|
16
|
+
} from '../exec/index.js'
|
|
17
|
+
import { prepareSandbox } from './sandbox-request.js'
|
|
18
|
+
import { forwardedSignal, terminateChildren } from './signals.js'
|
|
19
|
+
|
|
20
|
+
export interface LocalFloor {
|
|
21
|
+
execute(req: ExecuteRequest): Promise<ExecuteResult>
|
|
22
|
+
/** Arms the sandbox runtime for `sandboxes` from the next sandboxed task on; its servers keep running. */
|
|
23
|
+
rearm(sandboxes: readonly NonNullable<ExecConfig['sandbox']>[]): Promise<void>
|
|
24
|
+
/** Stops the servers this started and the sandbox runtime this armed. */
|
|
25
|
+
close(): Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `sandboxes`, in a process that is no run, are the `exec.sandbox` configs
|
|
30
|
+
* of every sandboxed task the run holds: the runtime's proxy and its
|
|
31
|
+
* unix-socket filter are one per process, set from their union before the
|
|
32
|
+
* first sandboxed task (`prepareSandbox`). A request that carries a
|
|
33
|
+
* `signal` but no `liveChildren` has its children stopped here when it
|
|
34
|
+
* aborts, the way a run's stop ends its own.
|
|
35
|
+
*/
|
|
36
|
+
export function localFloor(
|
|
37
|
+
sandboxes: readonly NonNullable<ExecConfig['sandbox']>[] = [],
|
|
38
|
+
): LocalFloor {
|
|
39
|
+
const local = localExecutor()
|
|
40
|
+
const prepare = (list: typeof sandboxes) =>
|
|
41
|
+
prepareSandbox(list.map((sandbox) => ({ config: { exec: { sandbox } } })))
|
|
42
|
+
let armer = prepare(sandboxes)
|
|
43
|
+
// Each server, by task id: shared by every task here that depends on it.
|
|
44
|
+
const servers = new Map<string, Promise<ReturnType<typeof Bun.spawn>>>()
|
|
45
|
+
const children: ReturnType<typeof Bun.spawn>[] = []
|
|
46
|
+
const serve = (spec: ServerSpec): Promise<unknown> => {
|
|
47
|
+
let up = servers.get(spec.taskId)
|
|
48
|
+
if (up === undefined) {
|
|
49
|
+
const spawn = runPersistent({
|
|
50
|
+
command: spec.command,
|
|
51
|
+
cwd: spec.cwd,
|
|
52
|
+
env: spec.env,
|
|
53
|
+
onStdout: () => {},
|
|
54
|
+
onStderr: () => {},
|
|
55
|
+
...(spec.readyWhen === undefined ? {} : { readyWhen: spec.readyWhen }),
|
|
56
|
+
...(spec.timeoutMs === undefined ? {} : { timeoutMs: spec.timeoutMs }),
|
|
57
|
+
})
|
|
58
|
+
children.push(spawn.child)
|
|
59
|
+
up = spawn.ready.then(() => spawn.child)
|
|
60
|
+
servers.set(spec.taskId, up)
|
|
61
|
+
// One that never became ready is asked again by the next task.
|
|
62
|
+
up.catch(() => servers.delete(spec.taskId))
|
|
63
|
+
}
|
|
64
|
+
return up
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
async execute(req) {
|
|
68
|
+
for (const spec of req.servers ?? []) {
|
|
69
|
+
try {
|
|
70
|
+
await serve(spec)
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const why = err instanceof Error ? err.message : String(err)
|
|
73
|
+
return {
|
|
74
|
+
exitCode: 1,
|
|
75
|
+
durationMs: 0,
|
|
76
|
+
stdout: '',
|
|
77
|
+
stderr: `${spec.taskId}, which ${req.taskId} depends on, did not start: ${why}\n`,
|
|
78
|
+
violations: [],
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (req.sandbox !== undefined) await armer?.arm()
|
|
83
|
+
const stop = req.signal
|
|
84
|
+
if (stop === undefined || req.liveChildren !== undefined) return local.execute(req)
|
|
85
|
+
// No run holds this task's children (an agent's): its stop is ours to carry out.
|
|
86
|
+
const live = new Set<ReturnType<typeof Bun.spawn>>()
|
|
87
|
+
const end = (): void => void terminateChildren(() => [...live], forwardedSignal(stop.reason))
|
|
88
|
+
stop.addEventListener('abort', end, { once: true })
|
|
89
|
+
try {
|
|
90
|
+
return await local.execute({ ...req, liveChildren: live })
|
|
91
|
+
} finally {
|
|
92
|
+
stop.removeEventListener('abort', end)
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
async rearm(list) {
|
|
96
|
+
if (armer?.armed === true) await resetSandbox()
|
|
97
|
+
armer = prepare(list)
|
|
98
|
+
},
|
|
99
|
+
async close() {
|
|
100
|
+
if (children.length > 0) await terminateChildren(() => children)
|
|
101
|
+
if (armer?.armed === true) await resetSandbox()
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -45,6 +45,7 @@ import { normalizeGlob, relPosix, span, staticPrefix } from '../util/index.js'
|
|
|
45
45
|
import type { CacheEntry, CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
46
46
|
import type { TaskNode } from '../graph/index.js'
|
|
47
47
|
import { deriveStableKeys, workspaceInputsReach } from './stable-keys.js'
|
|
48
|
+
import { readRunKeys, runKeysToken, writeRunKeys } from './run-keys.js'
|
|
48
49
|
import type { HashCache, UpfrontGroupKey } from './task-hash.js'
|
|
49
50
|
import { getContext } from './remote-prefetch.js'
|
|
50
51
|
|
|
@@ -100,6 +101,8 @@ export interface ShortCircuit {
|
|
|
100
101
|
uncachedKeys: Map<string, string>
|
|
101
102
|
/** Unkeyed group id → its up-front key (`deriveStableKeys`' `groupKeys`). */
|
|
102
103
|
groupKeys: Map<string, UpfrontGroupKey>
|
|
104
|
+
/** Behind a remote, each stable task the local store missed → its key: the remote may hold it. */
|
|
105
|
+
remoteAsk: Map<string, string>
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
/**
|
|
@@ -117,13 +120,22 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
117
120
|
const groupKeys = new Map<string, UpfrontGroupKey>()
|
|
118
121
|
const endKeys = span('stable keys')
|
|
119
122
|
try {
|
|
120
|
-
|
|
123
|
+
const at = runKeysToken(args)
|
|
124
|
+
const remembered = at === undefined ? undefined : readRunKeys(args, at)
|
|
125
|
+
if (remembered !== undefined) {
|
|
126
|
+
stableKeys = remembered.stableKeys
|
|
127
|
+
for (const [id, key] of remembered.uncachedKeys) uncachedKeys.set(id, key)
|
|
128
|
+
} else {
|
|
129
|
+
stableKeys = await deriveStableKeys({ ...args, uncachedKeys, groupKeys })
|
|
130
|
+
if (at !== undefined) writeRunKeys(args, at, { stableKeys, uncachedKeys })
|
|
131
|
+
}
|
|
121
132
|
} catch {
|
|
122
133
|
return {
|
|
123
134
|
preProbed: new Map(),
|
|
124
135
|
restoreTier: new Set(),
|
|
125
136
|
uncachedKeys: new Map(),
|
|
126
137
|
groupKeys: new Map(),
|
|
138
|
+
remoteAsk: new Map(),
|
|
127
139
|
}
|
|
128
140
|
} finally {
|
|
129
141
|
endKeys()
|
|
@@ -134,15 +146,16 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
134
146
|
const candidates = stableKeys
|
|
135
147
|
const preProbed = new Map<string, ProbedEntry>()
|
|
136
148
|
const restoreTier = new Set<string>()
|
|
149
|
+
const remoteAsk = new Map<string, string>()
|
|
137
150
|
if (candidates.length === 0 && uncachedKeys.size === 0) {
|
|
138
|
-
return { preProbed, restoreTier, uncachedKeys, groupKeys }
|
|
151
|
+
return { preProbed, restoreTier, uncachedKeys, groupKeys, remoteAsk }
|
|
139
152
|
}
|
|
140
153
|
|
|
141
154
|
const keptOut = restoreTierExclusions(args.nodes, args.workspaceRoot)
|
|
142
155
|
// A root-anchored output may land in a project no edge leads from, so an
|
|
143
156
|
// uncached key there is derived again once the writer may have run.
|
|
144
157
|
for (const id of keptOut) uncachedKeys.delete(id)
|
|
145
|
-
if (candidates.length === 0) return { preProbed, restoreTier, uncachedKeys, groupKeys }
|
|
158
|
+
if (candidates.length === 0) return { preProbed, restoreTier, uncachedKeys, groupKeys, remoteAsk }
|
|
146
159
|
|
|
147
160
|
// Bounded pool over the stable candidates: probe local ONCE each. A
|
|
148
161
|
// confirmed hit becomes restore-tier (unless workspace outputs disable
|
|
@@ -165,11 +178,14 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
165
178
|
endProbe()
|
|
166
179
|
for (const { hash, node } of candidates) {
|
|
167
180
|
const hit = hits.get(hash) ?? null
|
|
168
|
-
if (hit === null && args.localOnly === true)
|
|
181
|
+
if (hit === null && args.localOnly === true) {
|
|
182
|
+
if (!keptOut.has(node.id)) remoteAsk.set(node.id, hash)
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
169
185
|
preProbed.set(node.id, { hash, hit })
|
|
170
186
|
if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
|
|
171
187
|
}
|
|
172
|
-
return { preProbed, restoreTier, uncachedKeys, groupKeys }
|
|
188
|
+
return { preProbed, restoreTier, uncachedKeys, groupKeys, remoteAsk }
|
|
173
189
|
} catch {
|
|
174
190
|
// Fall through to the per-hash pool, which isolates a failing probe
|
|
175
191
|
// to its own task.
|
|
@@ -182,7 +198,10 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
182
198
|
const { hash, node } = candidates[next++]!
|
|
183
199
|
try {
|
|
184
200
|
const hit = await args.cache.get(hash, getContext(node, node.config.exec?.command ?? ''))
|
|
185
|
-
if (hit === null && args.localOnly === true)
|
|
201
|
+
if (hit === null && args.localOnly === true) {
|
|
202
|
+
if (!keptOut.has(node.id)) remoteAsk.set(node.id, hash)
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
186
205
|
preProbed.set(node.id, { hash, hit })
|
|
187
206
|
if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
|
|
188
207
|
} catch {
|
|
@@ -193,7 +212,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
193
212
|
}
|
|
194
213
|
await Promise.all(Array.from({ length: workers }, () => pump()))
|
|
195
214
|
|
|
196
|
-
return { preProbed, restoreTier, uncachedKeys, groupKeys }
|
|
215
|
+
return { preProbed, restoreTier, uncachedKeys, groupKeys, remoteAsk }
|
|
197
216
|
}
|
|
198
217
|
|
|
199
218
|
/**
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type CacheLayer,
|
|
14
14
|
type GitFilesCache,
|
|
15
15
|
resolveOutputTree,
|
|
16
|
+
outputWalkTakes,
|
|
16
17
|
resolveOutputs,
|
|
17
18
|
resolveWorkspaceOutputs,
|
|
18
19
|
WORKSPACE_OUTPUT_PREFIX,
|
|
@@ -20,11 +21,12 @@ import {
|
|
|
20
21
|
import type { TaskNode } from '../graph/index.js'
|
|
21
22
|
import {
|
|
22
23
|
asTrees,
|
|
24
|
+
outputDirPrefixes,
|
|
23
25
|
relPosix,
|
|
24
26
|
span,
|
|
27
|
+
splitNegations,
|
|
25
28
|
staticPrefix,
|
|
26
29
|
taskGlob,
|
|
27
|
-
wholeSubtreePrefixes,
|
|
28
30
|
} from '../util/index.js'
|
|
29
31
|
import { type Logger, warnOf } from '../report/index.js'
|
|
30
32
|
import type { TaskInputComponent } from '../cache/index.js'
|
|
@@ -49,8 +51,12 @@ export interface OutputDirSnapshot {
|
|
|
49
51
|
*/
|
|
50
52
|
export function entryHolds(
|
|
51
53
|
node: TaskNode,
|
|
54
|
+
takes: (rel: string) => boolean,
|
|
52
55
|
): (files: readonly string[], rows: ReadonlyArray<{ path: string }>) => boolean {
|
|
53
|
-
return (
|
|
56
|
+
return (walked, rows) => {
|
|
57
|
+
// A root-anchored glob's snapshot walks the whole project: only what
|
|
58
|
+
// the glob takes is the entry's.
|
|
59
|
+
const files = walked.filter(takes)
|
|
54
60
|
const expected = new Set(
|
|
55
61
|
rows
|
|
56
62
|
.map((r) => r.path)
|
|
@@ -250,13 +256,13 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
250
256
|
// list gets no snapshot: recording here was that same refusal (item
|
|
251
257
|
// 637 deleted the fallback and nothing reddened), and the next hit
|
|
252
258
|
// walks, as it did.
|
|
253
|
-
const savedDirPrefixes =
|
|
259
|
+
const savedDirPrefixes = outputDirPrefixes(splitNegations(a.outputs).positive)
|
|
254
260
|
if (savedDirPrefixes !== null && a.outputDirSnapshots !== undefined) {
|
|
255
261
|
a.outputDirSnapshots.push({
|
|
256
262
|
hash: a.hash,
|
|
257
263
|
projectDir: node.projectDir,
|
|
258
264
|
prefixes: savedDirPrefixes,
|
|
259
|
-
holds: entryHolds(node),
|
|
265
|
+
holds: entryHolds(node, outputWalkTakes(node.projectDir, a.outputs, a.nestedProjectDirs)),
|
|
260
266
|
})
|
|
261
267
|
}
|
|
262
268
|
}
|
|
@@ -92,14 +92,6 @@ export interface RunOptions {
|
|
|
92
92
|
remoteRequested?: boolean
|
|
93
93
|
/** This run's cache scope, above `VX_CACHE_SCOPE`, a plugin's and the branch's (an embedder's). */
|
|
94
94
|
cacheScope?: string
|
|
95
|
-
/**
|
|
96
|
-
* What the branch changed, for a scoped run: the tasks whose key it can
|
|
97
|
-
* move read the scope's key, every other task the default key (one
|
|
98
|
-
* lookup each). Absent, the run diffs the branch from `scopeBase`.
|
|
99
|
-
*/
|
|
100
|
-
scopeChanges?: AffectedChanges
|
|
101
|
-
/** The commit a scoped run diffs the branch from (`--affected`'s base); absent, a plugin's, else the merge base with the default branch. */
|
|
102
|
-
scopeBase?: string
|
|
103
95
|
/**
|
|
104
96
|
* The default policy's remote write is off (`remote:r`): the CLI passes it
|
|
105
97
|
* off CI (`CI` unset, `0` or `false`) unless `--cache-policy` or
|
|
@@ -73,6 +73,20 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
73
73
|
...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
|
|
74
74
|
})))
|
|
75
75
|
|
|
76
|
+
// Every dependant starts it where it runs (`ServerSpec`): nothing to spawn
|
|
77
|
+
// here, and nothing that ran (owner, 2026-10-22: nothing runs on a
|
|
78
|
+
// distributed run's orchestrator).
|
|
79
|
+
if (args.carried === true)
|
|
80
|
+
return {
|
|
81
|
+
node,
|
|
82
|
+
status: 'success',
|
|
83
|
+
exitCode: 0,
|
|
84
|
+
durationMs: 0,
|
|
85
|
+
...(hash === undefined ? {} : { hash }),
|
|
86
|
+
wallclockStartNs,
|
|
87
|
+
wallclockEndNs: wallclockStartNs,
|
|
88
|
+
}
|
|
89
|
+
|
|
76
90
|
// The args after `--` reach a server as they reach any task. A readyWhen
|
|
77
91
|
// server once got none — "so the matcher sees the unmodified output",
|
|
78
92
|
// though the args change the command, not what the matcher reads — and
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Where each task runs: the placement of a graph over the resolved
|
|
2
2
|
// executors, and the plan-mode view of it. Split from run.ts on 2026-09-10
|
|
3
3
|
// (pure motion). A task is pinned to this machine when it is persistent,
|
|
4
|
-
// depends on a persistent task, is sandboxed, says `exec.remote: false`, or
|
|
4
|
+
// depends on a persistent task no `servers` executor carries, is sandboxed, says `exec.remote: false`, or
|
|
5
5
|
// folds a runtime probe into its key; an `exec.interactive` task goes to the
|
|
6
6
|
// local floor directly, as only it can hand over this terminal;
|
|
7
7
|
// everything else asks the executors in declaration order, and the local
|
|
@@ -14,6 +14,7 @@ import { resolveDownloadModes } from './download-policy.js'
|
|
|
14
14
|
import { type Logger, warnOf } from '../report/index.js'
|
|
15
15
|
import { executorLabel, resolveExecutors } from './plugin-host.js'
|
|
16
16
|
import type { prepareRun } from './prepare.js'
|
|
17
|
+
import { cacheFacts, taskArtifacts } from './task-artifacts.js'
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* A task is pinned to this machine when it is persistent, transitively
|
|
@@ -22,20 +23,29 @@ import type { prepareRun } from './prepare.js'
|
|
|
22
23
|
* machinery — a worker has none of it, and a boundary "verified" where it
|
|
23
24
|
* is not enforced passes vacuously), or declares `exec.remote: false`. A
|
|
24
25
|
* dependant of a pinned task is pinned with it. A task whose key folds a
|
|
25
|
-
* runtime probe is pinned alone (`withProbedRuntime`).
|
|
26
|
+
* runtime probe is pinned alone (`withProbedRuntime`). `sandbox: false`
|
|
27
|
+
* leaves `exec.sandbox` out: the set for an executor that enforces it where
|
|
28
|
+
* it runs (`TaskExecutor.sandbox`).
|
|
26
29
|
*/
|
|
27
|
-
export function pinnedLocalSet(
|
|
30
|
+
export function pinnedLocalSet(
|
|
31
|
+
nodes: Map<string, TaskNode>,
|
|
32
|
+
sandbox = true,
|
|
33
|
+
/** For a `servers` executor: a carriable persistent task pins nothing. */
|
|
34
|
+
servers = false,
|
|
35
|
+
/** `false` for a `runtime` executor: it checks the probes where it runs the task. */
|
|
36
|
+
runtime = true,
|
|
37
|
+
): Set<string> {
|
|
28
38
|
const pinned = new Set<string>()
|
|
29
39
|
for (const node of nodes.values()) {
|
|
30
40
|
if (
|
|
31
|
-
node.config.exec?.persistent !== undefined ||
|
|
32
|
-
node.config.exec?.sandbox !== undefined ||
|
|
41
|
+
(node.config.exec?.persistent !== undefined && !(servers && carriable(node))) ||
|
|
42
|
+
(sandbox && node.config.exec?.sandbox !== undefined) ||
|
|
33
43
|
node.config.exec?.remote === false
|
|
34
44
|
) {
|
|
35
45
|
pinned.add(node.id)
|
|
36
46
|
}
|
|
37
47
|
}
|
|
38
|
-
if (pinned.size === 0) return withProbedRuntime(nodes, pinned)
|
|
48
|
+
if (pinned.size === 0) return runtime ? withProbedRuntime(nodes, pinned) : pinned
|
|
39
49
|
// Pinning flows up the dependant edges from what pins itself, one walk on
|
|
40
50
|
// an explicit stack: a chain is as deep as the graph, and a recursion
|
|
41
51
|
// per edge threw `RangeError` on the 50,000 the builder takes (item 737).
|
|
@@ -55,14 +65,50 @@ export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
|
|
|
55
65
|
stack.push(up)
|
|
56
66
|
}
|
|
57
67
|
}
|
|
58
|
-
return withProbedRuntime(nodes, pinned)
|
|
68
|
+
return runtime ? withProbedRuntime(nodes, pinned) : pinned
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A persistent task a `servers` executor can start where it runs a
|
|
73
|
+
* dependant: one the run holds for its dependants only, with no sandbox
|
|
74
|
+
* (this machine's machinery) and no `exec.remote: false`.
|
|
75
|
+
*/
|
|
76
|
+
function carriable(node: TaskNode): boolean {
|
|
77
|
+
const exec = node.config.exec
|
|
78
|
+
return (
|
|
79
|
+
exec?.persistent !== undefined &&
|
|
80
|
+
!node.requested &&
|
|
81
|
+
node.surfaced !== true &&
|
|
82
|
+
exec.sandbox === undefined &&
|
|
83
|
+
exec.remote !== false &&
|
|
84
|
+
exec.interactive !== true
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The persistent tasks `node` depends on through groups and servers, each after its own. */
|
|
89
|
+
function serversBehind(nodes: ReadonlyMap<string, TaskNode>, node: TaskNode): TaskNode[] {
|
|
90
|
+
const out: TaskNode[] = []
|
|
91
|
+
const seen = new Set<string>()
|
|
92
|
+
const visit = (id: string): void => {
|
|
93
|
+
if (seen.has(id)) return
|
|
94
|
+
seen.add(id)
|
|
95
|
+
const dep = nodes.get(id)
|
|
96
|
+
if (dep === undefined) return
|
|
97
|
+
const server = dep.config.exec?.persistent !== undefined
|
|
98
|
+
if (!server && !isGroupTask(dep)) return
|
|
99
|
+
for (const d of dep.deps) visit(d)
|
|
100
|
+
if (server) out.push(dep)
|
|
101
|
+
}
|
|
102
|
+
for (const d of node.deps) visit(d)
|
|
103
|
+
return out
|
|
59
104
|
}
|
|
60
105
|
|
|
61
106
|
/**
|
|
62
107
|
* A key that folds `inputs.runtime` / `workspaceRuntime` holds what
|
|
63
108
|
* THIS machine answered (`node -v`); a worker runs its own runtime, which no
|
|
64
109
|
* executor can prove equal, and its output saved under this key is a stale
|
|
65
|
-
* hit here (C-2). Such a task runs here
|
|
110
|
+
* hit here (C-2). Such a task runs here, or on a `runtime` executor, which
|
|
111
|
+
* fails it where its probes answer otherwise. Its dependants are not pinned with
|
|
66
112
|
* it: they fold the probe through its input key, and their own output does
|
|
67
113
|
* not depend on this machine's runtime.
|
|
68
114
|
*/
|
|
@@ -131,6 +177,10 @@ export interface Placements {
|
|
|
131
177
|
remoteOnlyNoop: Set<string>
|
|
132
178
|
/** `'only'` tasks a remote executor DID take — executed remotely, outputs stay remote. */
|
|
133
179
|
remoteOnly: Set<string>
|
|
180
|
+
/** Each task placed on a `servers` executor → the persistent tasks it starts there. */
|
|
181
|
+
servers: Map<string, TaskNode[]>
|
|
182
|
+
/** Persistent tasks every dependant of which carries them: never started here. */
|
|
183
|
+
carried: Set<string>
|
|
134
184
|
}
|
|
135
185
|
|
|
136
186
|
/**
|
|
@@ -156,12 +206,27 @@ export function placeTasks(
|
|
|
156
206
|
nodes: Map<string, TaskNode>,
|
|
157
207
|
executors: readonly TaskExecutor[],
|
|
158
208
|
): Placements {
|
|
159
|
-
|
|
209
|
+
// Each executor's pins, computed only when one reads them.
|
|
210
|
+
const pins = new Map<string, Set<string>>()
|
|
211
|
+
const pinsOf = (e: Pick<TaskExecutor, 'sandbox' | 'servers' | 'runtime'>): Set<string> => {
|
|
212
|
+
const k = `${e.sandbox === true}${e.servers === true}${e.runtime === true}`
|
|
213
|
+
let set = pins.get(k)
|
|
214
|
+
if (set === undefined) {
|
|
215
|
+
set = pinnedLocalSet(nodes, e.sandbox !== true, e.servers === true, e.runtime !== true)
|
|
216
|
+
pins.set(k, set)
|
|
217
|
+
}
|
|
218
|
+
return set
|
|
219
|
+
}
|
|
220
|
+
const pinned = pinsOf({})
|
|
160
221
|
const placements: Placements = {
|
|
161
222
|
executors: new Map(),
|
|
162
223
|
remoteOnlyNoop: new Set(),
|
|
163
224
|
remoteOnly: new Set(),
|
|
225
|
+
servers: new Map(),
|
|
226
|
+
carried: new Set(),
|
|
164
227
|
}
|
|
228
|
+
/** Persistent tasks a dependant placed off a `servers` executor needs here. */
|
|
229
|
+
const heldHere = new Set<string>()
|
|
165
230
|
// `resolveExecutors` puts it last in every list.
|
|
166
231
|
const floor = executors.find(isLocalExecutor)!
|
|
167
232
|
for (const node of nodes.values()) {
|
|
@@ -181,8 +246,14 @@ export function placeTasks(
|
|
|
181
246
|
cacheable: node.config.cache !== undefined,
|
|
182
247
|
},
|
|
183
248
|
executorLabel,
|
|
249
|
+
(e) => pinsOf(e).has(node.id),
|
|
184
250
|
)
|
|
185
251
|
placements.executors.set(node.id, executor)
|
|
252
|
+
const servers = serversBehind(nodes, node)
|
|
253
|
+
if (servers.length > 0) {
|
|
254
|
+
if (executor.servers === true) placements.servers.set(node.id, servers)
|
|
255
|
+
else for (const s of servers) heldHere.add(s.id)
|
|
256
|
+
}
|
|
186
257
|
if (node.config.exec?.remote === 'only') {
|
|
187
258
|
// A pinned 'only' task (it transitively depends on a persistent one)
|
|
188
259
|
// lands here too: pinning wins, so it noops rather than shipping.
|
|
@@ -190,6 +261,8 @@ export function placeTasks(
|
|
|
190
261
|
else placements.remoteOnlyNoop.add(node.id)
|
|
191
262
|
}
|
|
192
263
|
}
|
|
264
|
+
for (const servers of placements.servers.values())
|
|
265
|
+
for (const s of servers) if (!heldHere.has(s.id)) placements.carried.add(s.id)
|
|
193
266
|
return placements
|
|
194
267
|
}
|
|
195
268
|
|
|
@@ -220,6 +293,8 @@ export async function planExecutorOf(
|
|
|
220
293
|
cacheDir: prepared.cacheDir,
|
|
221
294
|
warn: warnOf(log),
|
|
222
295
|
concurrency: machineParallelism(),
|
|
296
|
+
artifacts: taskArtifacts(prepared.cache, prepared.workspaceRoot),
|
|
297
|
+
cacheFacts: () => cacheFacts(prepared.remotes, prepared.cacheRefs),
|
|
223
298
|
})
|
|
224
299
|
} catch (err) {
|
|
225
300
|
warnOf(log)(
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface PlannedTask {
|
|
|
54
54
|
* eager task is the default and saying so on every line says nothing.
|
|
55
55
|
*/
|
|
56
56
|
download?: 'deferred'
|
|
57
|
+
/** A remote hit's stored size, when the remote said it: what restoring it downloads. */
|
|
58
|
+
remoteBytes?: number
|
|
57
59
|
/** Why `--affected` kept this requested task; absent without `--affected` and on a dependency it pulled in. */
|
|
58
60
|
affected?: AffectedReason
|
|
59
61
|
}
|
|
@@ -139,6 +141,7 @@ export interface PlanArgs {
|
|
|
139
141
|
*/
|
|
140
142
|
export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
141
143
|
const cacheStatusById = new Map<string, CacheStatus>()
|
|
144
|
+
const remoteBytesById = new Map<string, number>()
|
|
142
145
|
// Keyed first, probed after: a remote that can batch (`hasMany`) is
|
|
143
146
|
// asked once for every key, one that cannot is asked per key.
|
|
144
147
|
const probed = new Map<string, string>()
|
|
@@ -191,13 +194,15 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
191
194
|
},
|
|
192
195
|
})
|
|
193
196
|
if (probed.size > 0) {
|
|
194
|
-
await args.cache.remoteHasMany?.([...new Set(probed.values())])
|
|
197
|
+
const infos = await args.cache.remoteHasMany?.([...new Set(probed.values())])
|
|
195
198
|
for (const [id, hash] of probed) {
|
|
196
199
|
const where = await args.cache.has(hash)
|
|
197
200
|
cacheStatusById.set(
|
|
198
201
|
id,
|
|
199
202
|
where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local',
|
|
200
203
|
)
|
|
204
|
+
const size = where === 'remote' ? infos?.get(hash)?.size : undefined
|
|
205
|
+
if (size !== undefined) remoteBytesById.set(id, size)
|
|
201
206
|
}
|
|
202
207
|
}
|
|
203
208
|
|
|
@@ -225,6 +230,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
225
230
|
deps: node.deps,
|
|
226
231
|
...(executor !== undefined ? { executor } : {}),
|
|
227
232
|
...(args.downloadOf?.(id) === 'deferred' ? { download: 'deferred' as const } : {}),
|
|
233
|
+
...(remoteBytesById.has(id) ? { remoteBytes: remoteBytesById.get(id)! } : {}),
|
|
228
234
|
})
|
|
229
235
|
}
|
|
230
236
|
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import {
|
|
12
|
-
type BranchScope,
|
|
13
12
|
type Cache,
|
|
14
13
|
type CacheLayer,
|
|
15
14
|
type CachePolicy,
|
|
@@ -428,11 +427,12 @@ export interface ScheduleRun {
|
|
|
428
427
|
export async function applyScheduleHooks(
|
|
429
428
|
plugins: readonly VxPlugin[],
|
|
430
429
|
nodes: ReadonlyMap<string, TaskNode>,
|
|
431
|
-
ctx: Omit<ScheduleHookContext, 'cache' | 'refine'>,
|
|
430
|
+
ctx: Omit<ScheduleHookContext, 'cache' | 'refine' | 'onRefine'>,
|
|
432
431
|
cacheFor: (plugin: string) => PluginCache = () => absentPluginCache,
|
|
433
432
|
run: ScheduleRun = {},
|
|
434
433
|
): Promise<ReadonlyMap<string, number>> {
|
|
435
434
|
const sets: (ReadonlyMap<string, number> | undefined)[] = []
|
|
435
|
+
const listeners: { plugin: VxPlugin; i: number; listener: () => void }[] = []
|
|
436
436
|
const merged = (): ReadonlyMap<string, number> => {
|
|
437
437
|
const out = new Map<string, number>()
|
|
438
438
|
for (const set of sets) if (set !== undefined) for (const [id, w] of set) out.set(id, w)
|
|
@@ -449,8 +449,21 @@ export async function applyScheduleHooks(
|
|
|
449
449
|
return
|
|
450
450
|
}
|
|
451
451
|
run.refined(merged())
|
|
452
|
+
for (const l of listeners) {
|
|
453
|
+
if (l.i === i) continue
|
|
454
|
+
try {
|
|
455
|
+
l.listener()
|
|
456
|
+
} catch (err) {
|
|
457
|
+
ctx.warn(
|
|
458
|
+
`[vx] plugin '${l.plugin.name}' failed in onRefine: ${err instanceof Error ? err.message : String(err)}`,
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const onRefine = (listener: () => void): void => {
|
|
464
|
+
if (run.refined !== undefined) listeners.push({ plugin, i, listener })
|
|
452
465
|
}
|
|
453
|
-
const own = { ...ctx, cache: cacheFor(plugin.name), refine }
|
|
466
|
+
const own = { ...ctx, cache: cacheFor(plugin.name), refine, onRefine }
|
|
454
467
|
const call = (): Promise<ReadonlyMap<string, number> | undefined> =>
|
|
455
468
|
safe(plugin, 'schedule', () => plugin.schedule!(nodes, own))
|
|
456
469
|
const weights = await (run.step === undefined
|
|
@@ -556,10 +569,10 @@ export interface CacheHost extends CacheContext {
|
|
|
556
569
|
readonly policy: CachePolicy
|
|
557
570
|
/** Filled with each remote and what its policy lets it do, for the plugins' `ctx.cache`. */
|
|
558
571
|
readonly remotes?: ResolvedRemote[]
|
|
559
|
-
/** The run's branch scope, which picks each remote key. */
|
|
560
|
-
readonly branch?: BranchScope
|
|
561
572
|
/** Uploads at once, per remote (`cache.concurrency.upload`). */
|
|
562
573
|
readonly uploadConcurrency?: number
|
|
574
|
+
/** What another machine's run lent, by plugin: each plugin's `ctx.lent`. */
|
|
575
|
+
readonly lentBy?: () => Readonly<Record<string, string>> | undefined
|
|
563
576
|
}
|
|
564
577
|
|
|
565
578
|
/**
|
|
@@ -574,10 +587,12 @@ export async function resolveCache(
|
|
|
574
587
|
plugins: readonly VxPlugin[],
|
|
575
588
|
host: CacheHost,
|
|
576
589
|
): Promise<CacheLayer> {
|
|
577
|
-
const { localCache, policy, remotes,
|
|
590
|
+
const { localCache, policy, remotes, uploadConcurrency, lentBy, ...base } = host
|
|
578
591
|
const resolved: ResolvedRemote[] = []
|
|
579
592
|
for (const plugin of plugins) {
|
|
580
593
|
if (plugin.cache === undefined) continue
|
|
594
|
+
const ctx: CacheContext =
|
|
595
|
+
lentBy === undefined ? base : { ...base, lent: () => lentBy()?.[plugin.name] }
|
|
581
596
|
const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
|
|
582
597
|
if (remote === undefined) continue
|
|
583
598
|
assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
|
|
@@ -593,7 +608,7 @@ export async function resolveCache(
|
|
|
593
608
|
}
|
|
594
609
|
return resolved.length === 0
|
|
595
610
|
? localCache
|
|
596
|
-
: new LayeredCache(localCache, resolved,
|
|
611
|
+
: new LayeredCache(localCache, resolved, uploadConcurrency)
|
|
597
612
|
}
|
|
598
613
|
|
|
599
614
|
/** The plugin each resolved executor came from, so a hook of it that throws is named. */
|