@vzn/vx 0.0.645 → 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/README.md +10 -28
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/skills/vx/SKILL.md +1 -1
- package/src/bin.ts +1 -1
- package/src/cache/cache.ts +66 -16
- package/src/cache/git-inputs.ts +55 -10
- package/src/cache/index.ts +4 -0
- package/src/cache/inputs.ts +61 -11
- package/src/cache/layer.ts +25 -2
- package/src/cache/layered-cache.ts +32 -2
- package/src/cache/output-index.ts +25 -11
- package/src/cache/remote-lane.ts +13 -4
- package/src/cache/run-history.ts +13 -6
- package/src/cache/schema.ts +19 -2
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/help.ts +4 -6
- package/src/cli/index.ts +0 -12
- package/src/cli/init.ts +43 -10
- package/src/cli/last.ts +4 -2
- package/src/cli/plugin-commands.ts +96 -3
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +2 -7
- package/src/cli/select.ts +5 -3
- package/src/cli/upgrade.ts +4 -5
- 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 +15 -0
- 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 +17 -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/persistent-task.ts +14 -0
- package/src/orchestrator/placement.ts +84 -9
- package/src/orchestrator/plugin-host.ts +21 -3
- package/src/orchestrator/plugin.ts +37 -0
- package/src/orchestrator/prepare.ts +11 -3
- package/src/orchestrator/run-keys.ts +164 -0
- package/src/orchestrator/run-records.ts +6 -0
- package/src/orchestrator/run.ts +93 -6
- package/src/orchestrator/sandbox-request.ts +9 -3
- package/src/orchestrator/task-artifacts.ts +142 -0
- package/src/orchestrator/task-hash.ts +11 -1
- 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 +61 -7
- package/src/settings.ts +7 -0
- 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/config-bytecode.ts +276 -0
- package/src/workspace/index.ts +0 -1
- package/src/workspace/migration.ts +4 -32
- package/src/workspace/project-loader.ts +92 -14
- package/src/cli/foreign-flags.ts +0 -727
|
@@ -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
|
}
|
|
@@ -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)(
|
|
@@ -427,11 +427,12 @@ export interface ScheduleRun {
|
|
|
427
427
|
export async function applyScheduleHooks(
|
|
428
428
|
plugins: readonly VxPlugin[],
|
|
429
429
|
nodes: ReadonlyMap<string, TaskNode>,
|
|
430
|
-
ctx: Omit<ScheduleHookContext, 'cache' | 'refine'>,
|
|
430
|
+
ctx: Omit<ScheduleHookContext, 'cache' | 'refine' | 'onRefine'>,
|
|
431
431
|
cacheFor: (plugin: string) => PluginCache = () => absentPluginCache,
|
|
432
432
|
run: ScheduleRun = {},
|
|
433
433
|
): Promise<ReadonlyMap<string, number>> {
|
|
434
434
|
const sets: (ReadonlyMap<string, number> | undefined)[] = []
|
|
435
|
+
const listeners: { plugin: VxPlugin; i: number; listener: () => void }[] = []
|
|
435
436
|
const merged = (): ReadonlyMap<string, number> => {
|
|
436
437
|
const out = new Map<string, number>()
|
|
437
438
|
for (const set of sets) if (set !== undefined) for (const [id, w] of set) out.set(id, w)
|
|
@@ -448,8 +449,21 @@ export async function applyScheduleHooks(
|
|
|
448
449
|
return
|
|
449
450
|
}
|
|
450
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 })
|
|
451
465
|
}
|
|
452
|
-
const own = { ...ctx, cache: cacheFor(plugin.name), refine }
|
|
466
|
+
const own = { ...ctx, cache: cacheFor(plugin.name), refine, onRefine }
|
|
453
467
|
const call = (): Promise<ReadonlyMap<string, number> | undefined> =>
|
|
454
468
|
safe(plugin, 'schedule', () => plugin.schedule!(nodes, own))
|
|
455
469
|
const weights = await (run.step === undefined
|
|
@@ -557,6 +571,8 @@ export interface CacheHost extends CacheContext {
|
|
|
557
571
|
readonly remotes?: ResolvedRemote[]
|
|
558
572
|
/** Uploads at once, per remote (`cache.concurrency.upload`). */
|
|
559
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
|
|
560
576
|
}
|
|
561
577
|
|
|
562
578
|
/**
|
|
@@ -571,10 +587,12 @@ export async function resolveCache(
|
|
|
571
587
|
plugins: readonly VxPlugin[],
|
|
572
588
|
host: CacheHost,
|
|
573
589
|
): Promise<CacheLayer> {
|
|
574
|
-
const { localCache, policy, remotes, uploadConcurrency, ...
|
|
590
|
+
const { localCache, policy, remotes, uploadConcurrency, lentBy, ...base } = host
|
|
575
591
|
const resolved: ResolvedRemote[] = []
|
|
576
592
|
for (const plugin of plugins) {
|
|
577
593
|
if (plugin.cache === undefined) continue
|
|
594
|
+
const ctx: CacheContext =
|
|
595
|
+
lentBy === undefined ? base : { ...base, lent: () => lentBy()?.[plugin.name] }
|
|
578
596
|
const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
|
|
579
597
|
if (remote === undefined) continue
|
|
580
598
|
assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
|
|
@@ -24,6 +24,7 @@ import { maskedLine, nearest, type PluginSecrets, SecretBroker, UserError } from
|
|
|
24
24
|
import type { ProjectMeta } from '../workspace/index.js'
|
|
25
25
|
import type { EventBus, Logger, RunStartInfo } from '../report/index.js'
|
|
26
26
|
import type { CiContext } from './run-context.js'
|
|
27
|
+
import type { CacheFacts, TaskArtifacts } from './task-artifacts.js'
|
|
27
28
|
import type { TelemetryContext, TelemetrySink } from './telemetry.js'
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -334,6 +335,20 @@ export interface CommandContext extends BaseContext {
|
|
|
334
335
|
* workspace's cache plugins supply, under the run's scope. Opened on first use.
|
|
335
336
|
*/
|
|
336
337
|
readonly cache: PluginCache
|
|
338
|
+
/**
|
|
339
|
+
* Task entries by key in the workspace's cache, under a run's policy and
|
|
340
|
+
* scope (a laptop writes no remote unless the workspace's policy names
|
|
341
|
+
* it): what a verb that runs a run's tasks on this machine saves and
|
|
342
|
+
* restores through (`TaskArtifacts`). Opened on first use.
|
|
343
|
+
*/
|
|
344
|
+
readonly artifacts: TaskArtifacts
|
|
345
|
+
/**
|
|
346
|
+
* `artifacts` read as another machine's run would read them: under its
|
|
347
|
+
* refs, with the access its remotes lent (`CacheFacts`, asked on each
|
|
348
|
+
* request, so a token it lends again replaces the last). Opened on first
|
|
349
|
+
* use; until `facts` answers, under this machine's own.
|
|
350
|
+
*/
|
|
351
|
+
artifactsFor(facts: () => CacheFacts | undefined): TaskArtifacts
|
|
337
352
|
}
|
|
338
353
|
|
|
339
354
|
/** `config` runs before the cache dir is known — it may be what the hook changes. */
|
|
@@ -500,6 +515,13 @@ export interface ScheduleHookContext extends BaseContext {
|
|
|
500
515
|
* from a remote). Ignored in a plan and once the run's tasks are done.
|
|
501
516
|
*/
|
|
502
517
|
refine(weights: ReadonlyMap<string, number>): void
|
|
518
|
+
/**
|
|
519
|
+
* Calls `listener` each time another plugin `refine`s: the late data it
|
|
520
|
+
* waited for (timings fetched from a remote) has landed in history, so a
|
|
521
|
+
* plugin that read history in `schedule` may read it again. Never called
|
|
522
|
+
* in a plan.
|
|
523
|
+
*/
|
|
524
|
+
onRefine(listener: () => void): void
|
|
503
525
|
}
|
|
504
526
|
|
|
505
527
|
/** A `cache` hook's context: the workspace and the run's scope, never the local store or the policy, which core owns. */
|
|
@@ -515,6 +537,13 @@ export interface CacheContext extends BaseContext {
|
|
|
515
537
|
* keys or scopes its entries by them; core keys task entries by none.
|
|
516
538
|
*/
|
|
517
539
|
readonly refs: readonly string[]
|
|
540
|
+
/**
|
|
541
|
+
* The token a run lent this plugin's remote (`RemoteCacheLayer.lend`),
|
|
542
|
+
* on a machine that runs that run's tasks (`CommandContext.artifactsFor`);
|
|
543
|
+
* undefined anywhere else. Read it per request: a run lends again before
|
|
544
|
+
* a token ends. A plugin uses it only when it has no access of its own.
|
|
545
|
+
*/
|
|
546
|
+
readonly lent?: () => string | undefined
|
|
518
547
|
}
|
|
519
548
|
|
|
520
549
|
export interface OutputContext extends BaseContext {
|
|
@@ -527,6 +556,14 @@ export interface OutputContext extends BaseContext {
|
|
|
527
556
|
export interface ExecutorContext extends BaseContext {
|
|
528
557
|
/** The run's worker count — an executor that paces itself reads it here. */
|
|
529
558
|
readonly concurrency: number
|
|
559
|
+
/** Task entries by key in the run's cache, for a task another machine ran (`TaskArtifacts`). */
|
|
560
|
+
readonly artifacts: TaskArtifacts
|
|
561
|
+
/**
|
|
562
|
+
* What a machine that runs this run's tasks reads the cache under
|
|
563
|
+
* (`CacheFacts`): the run's refs, and each remote's lent access, asked
|
|
564
|
+
* now. Ask again before `expiresAt`.
|
|
565
|
+
*/
|
|
566
|
+
cacheFacts(): Promise<CacheFacts>
|
|
530
567
|
}
|
|
531
568
|
|
|
532
569
|
export interface PluginContext extends PluginSetupContext {
|
|
@@ -116,6 +116,8 @@ export interface PreparedRun {
|
|
|
116
116
|
cacheScope: string | undefined
|
|
117
117
|
/** The run's remotes in declared order, as the policy gates each; `openRemotes` turns one off. */
|
|
118
118
|
remotes: ResolvedRemote[]
|
|
119
|
+
/** The refs the run reads, in order (`CacheContext.refs`). */
|
|
120
|
+
cacheRefs: readonly string[]
|
|
119
121
|
/**
|
|
120
122
|
* Scheduling priorities from plugins' `schedule` stage (task id → weight,
|
|
121
123
|
* merged over the structural baseline by the scheduler). Empty when no
|
|
@@ -494,6 +496,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
494
496
|
onError: (err) => warnOf(log)(`[vx] remote cache: ${err.message}`),
|
|
495
497
|
})
|
|
496
498
|
}
|
|
499
|
+
// An injected remote is no plugin's: nothing reads refs for it.
|
|
500
|
+
const refs = options.remoteCache ? [] : cacheRefs(workspaceRoot, baseBranch)
|
|
497
501
|
try {
|
|
498
502
|
cache = options.remoteCache
|
|
499
503
|
? new LayeredCache(localCache, remotes, options.uploadConcurrency)
|
|
@@ -502,7 +506,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
502
506
|
cacheDir,
|
|
503
507
|
warn: warnOf(log),
|
|
504
508
|
scope: cacheScope,
|
|
505
|
-
refs
|
|
509
|
+
refs,
|
|
506
510
|
localCache,
|
|
507
511
|
policy,
|
|
508
512
|
remotes,
|
|
@@ -554,6 +558,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
554
558
|
cachePolicy: policy,
|
|
555
559
|
cacheScope,
|
|
556
560
|
remotes,
|
|
561
|
+
cacheRefs: refs,
|
|
557
562
|
keepRunEntry: undefined,
|
|
558
563
|
priorities: () => Promise.resolve(new Map()),
|
|
559
564
|
branch,
|
|
@@ -804,6 +809,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
804
809
|
cachePolicy: policy,
|
|
805
810
|
cacheScope,
|
|
806
811
|
remotes,
|
|
812
|
+
cacheRefs: refs,
|
|
807
813
|
priorities,
|
|
808
814
|
branch,
|
|
809
815
|
cacheFor,
|
|
@@ -915,7 +921,7 @@ function pruneToClosure(nodes: Map<string, TaskNode>, roots: ReadonlySet<string>
|
|
|
915
921
|
}
|
|
916
922
|
}
|
|
917
923
|
|
|
918
|
-
const namesRemote = (spec: string | undefined): boolean =>
|
|
924
|
+
export const namesRemote = (spec: string | undefined): boolean =>
|
|
919
925
|
spec !== undefined && /remote:[rw]/.test(spec)
|
|
920
926
|
|
|
921
927
|
/**
|
|
@@ -923,7 +929,9 @@ const namesRemote = (spec: string | undefined): boolean =>
|
|
|
923
929
|
* since the grammar lives in the cache module the workspace module may not
|
|
924
930
|
* import. Undefined when it names none.
|
|
925
931
|
*/
|
|
926
|
-
function workspaceCachePolicy(
|
|
932
|
+
export function workspaceCachePolicy(
|
|
933
|
+
config: WorkspaceConfig | null | undefined,
|
|
934
|
+
): CachePolicy | undefined {
|
|
927
935
|
const spec = config?.cache?.policy
|
|
928
936
|
if (spec === undefined) return undefined
|
|
929
937
|
try {
|