@vzn/vx 0.0.651 → 0.0.653
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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +142 -6
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +58 -31
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
|
@@ -19,6 +19,7 @@ import { warnOf } from '../report/index.js'
|
|
|
19
19
|
import type { ExecuteArgs } from './execute-task.js'
|
|
20
20
|
import {
|
|
21
21
|
commandWriteReach,
|
|
22
|
+
dependencyReads,
|
|
22
23
|
mayWriteFingerprint,
|
|
23
24
|
type Placeholder,
|
|
24
25
|
placeholderSweeper,
|
|
@@ -29,7 +30,7 @@ import {
|
|
|
29
30
|
} from './sandbox-request.js'
|
|
30
31
|
import { shellVerdict } from './shell-verdict.js'
|
|
31
32
|
import { forwardedSignal, terminateChildren } from './signals.js'
|
|
32
|
-
import { computeTaskHash } from './task-hash.js'
|
|
33
|
+
import { computeTaskHash, keyOf } from './task-hash.js'
|
|
33
34
|
import {
|
|
34
35
|
forgetUndeclaredWrites,
|
|
35
36
|
isAborted,
|
|
@@ -51,16 +52,18 @@ export function serverKey(args: ExecuteArgs): Promise<string | undefined> | stri
|
|
|
51
52
|
if (args.noDependants === true) return undefined
|
|
52
53
|
return (
|
|
53
54
|
args.upfrontKey ??
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
55
|
+
keyOf(
|
|
56
|
+
computeTaskHash({
|
|
57
|
+
node: args.node,
|
|
58
|
+
upstream: args.upstream,
|
|
59
|
+
workspaceRoot: args.workspaceRoot,
|
|
60
|
+
cache: args.cache,
|
|
61
|
+
forwardArgs: args.forwardArgs,
|
|
62
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
63
|
+
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
64
|
+
...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
64
67
|
)
|
|
65
68
|
}
|
|
66
69
|
|
|
@@ -128,6 +131,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
128
131
|
await args.armSandbox?.()
|
|
129
132
|
// A persistent task never declares `cache` (the loader refuses it), so
|
|
130
133
|
// it keeps the whole linked grant: no key of its can be stale.
|
|
134
|
+
const deps = await dependencyReads(args.readableDeps?.(node) ?? [], args.workspaceRoot)
|
|
131
135
|
const sb = await sandboxRequestFor(
|
|
132
136
|
node,
|
|
133
137
|
step.sandbox,
|
|
@@ -135,6 +139,8 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
135
139
|
undefined,
|
|
136
140
|
args.nestedProjectDirs,
|
|
137
141
|
args.cacheDir,
|
|
142
|
+
deps.reads,
|
|
143
|
+
deps.reported,
|
|
138
144
|
)
|
|
139
145
|
placeholders = sb.placeholders
|
|
140
146
|
serverSandbox = sb.sandbox
|
|
@@ -179,6 +185,17 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
179
185
|
: {}),
|
|
180
186
|
...(signalChannel ? { signalChannel } : {}),
|
|
181
187
|
...(args.terminal === true ? { terminal: true } : {}),
|
|
188
|
+
// What the pre-ready text left held goes out first, so the run can
|
|
189
|
+
// tell it from what the server writes since ready.
|
|
190
|
+
...(args.serverReady !== undefined
|
|
191
|
+
? {
|
|
192
|
+
onReady: () => {
|
|
193
|
+
serverMasked?.end()
|
|
194
|
+
ends.end()
|
|
195
|
+
args.serverReady!(node.id)
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
: {}),
|
|
182
199
|
}
|
|
183
200
|
if (step.persistent.readyWhen !== undefined) {
|
|
184
201
|
persistentOpts.readyWhen = step.persistent.readyWhen
|
|
@@ -242,7 +259,9 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
242
259
|
readyWhen === undefined
|
|
243
260
|
? undefined
|
|
244
261
|
: setTimeout(() => {
|
|
245
|
-
|
|
262
|
+
// A server that traps SIGTERM runs out the kill grace after the
|
|
263
|
+
// timeout gave up; the wait is over, and its failure says so.
|
|
264
|
+
if (isAborted(args.stopSignal) || spawn.gaveUp()) return
|
|
246
265
|
const after = noticeMs < 1000 ? `${noticeMs} ms` : `${noticeMs / 1000} s`
|
|
247
266
|
const unbounded = effectiveTimeout === undefined ? ', with no exec.timeout' : ''
|
|
248
267
|
warnOf(log)(
|
|
@@ -286,10 +305,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
286
305
|
// (an embedder) never saw a bare stderr write at all. What waited on
|
|
287
306
|
// an open line goes first.
|
|
288
307
|
ends.end()
|
|
289
|
-
log.taskStderr(
|
|
290
|
-
node,
|
|
291
|
-
ends.own(`[vx] ${node.id}: persistent task failed to become ready: ${message}\n`),
|
|
292
|
-
)
|
|
308
|
+
log.taskStderr(node, ends.own(`[vx] ${node.id}: not ready: ${message}\n`))
|
|
293
309
|
// A server's 127, 126 or signal death needs the line a one-shot task's
|
|
294
310
|
// gets (execute-task.ts): a dev server whose tool sits in a sibling's
|
|
295
311
|
// bin said only "exit 127".
|
|
@@ -41,13 +41,27 @@ export function selectKeepAlive(
|
|
|
41
41
|
): KeepAlive {
|
|
42
42
|
const out: KeepAlive = { nodes: [], children: [] }
|
|
43
43
|
if (!foreground || registry.size === 0) return out
|
|
44
|
+
const reached = keptServers(nodes, (id) => registry.has(id))
|
|
45
|
+
for (const [id, child] of registry) {
|
|
46
|
+
if (!reached.has(id)) continue
|
|
47
|
+
out.nodes.push(nodes.get(id)!)
|
|
48
|
+
out.children.push(child)
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The servers and groups `selectKeepAlive` keeps in the foreground: the requested ones and what they stand on. */
|
|
54
|
+
function keptServers(
|
|
55
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
56
|
+
isServer: (id: string) => boolean,
|
|
57
|
+
): Set<string> {
|
|
44
58
|
// A requested GROUP is a requested server's stand-in: `vx run app#dev`
|
|
45
59
|
// over `dev: { dependsOn: ['^dev'] }` started every server and exited 0
|
|
46
60
|
// at the end of the graph, stopping them all (C-52).
|
|
47
61
|
const stack: string[] = []
|
|
48
62
|
for (const [id, n] of nodes) {
|
|
49
63
|
if (!n.requested && n.surfaced !== true) continue
|
|
50
|
-
if (
|
|
64
|
+
if (isServer(id) || isGroupTask(n)) stack.push(id)
|
|
51
65
|
}
|
|
52
66
|
const reached = new Set<string>()
|
|
53
67
|
while (stack.length > 0) {
|
|
@@ -56,15 +70,120 @@ export function selectKeepAlive(
|
|
|
56
70
|
reached.add(id)
|
|
57
71
|
for (const dep of nodes.get(id)?.deps ?? []) {
|
|
58
72
|
const d = nodes.get(dep)
|
|
59
|
-
if (d !== undefined && (
|
|
73
|
+
if (d !== undefined && (isServer(dep) || isGroupTask(d))) stack.push(dep)
|
|
60
74
|
}
|
|
61
75
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
76
|
+
return reached
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The servers no task left needs, as tasks finish: a dependency-only
|
|
81
|
+
* server stops once every task that uses it, directly or through groups,
|
|
82
|
+
* has finished, not at the end of the graph. A server that uses another
|
|
83
|
+
* finishes only when it is stopped itself. `finished(id)` answers the
|
|
84
|
+
* servers to stop now; a server kept in the foreground never is.
|
|
85
|
+
*/
|
|
86
|
+
export function unneededServers(
|
|
87
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
88
|
+
foreground: boolean,
|
|
89
|
+
): (id: string) => string[] {
|
|
90
|
+
const isServer = (n: TaskNode): boolean => n.config.exec?.persistent !== undefined
|
|
91
|
+
const kept = foreground ? keptServers(nodes, (id) => isServer(nodes.get(id)!)) : new Set<string>()
|
|
92
|
+
const dependents = new Map<string, string[]>()
|
|
93
|
+
for (const [id, n] of nodes)
|
|
94
|
+
for (const d of n.deps) {
|
|
95
|
+
const list = dependents.get(d)
|
|
96
|
+
if (list === undefined) dependents.set(d, [id])
|
|
97
|
+
else list.push(id)
|
|
98
|
+
}
|
|
99
|
+
// Each server's users: its dependents, a group read through to its own.
|
|
100
|
+
const users = new Map<string, Set<string>>()
|
|
101
|
+
const usedBy = new Map<string, string[]>()
|
|
102
|
+
for (const [id, n] of nodes) {
|
|
103
|
+
if (!isServer(n) || kept.has(id)) continue
|
|
104
|
+
const set = new Set<string>()
|
|
105
|
+
const stack = [...(dependents.get(id) ?? [])]
|
|
106
|
+
const seen = new Set<string>()
|
|
107
|
+
while (stack.length > 0) {
|
|
108
|
+
const d = stack.pop()!
|
|
109
|
+
if (seen.has(d)) continue
|
|
110
|
+
seen.add(d)
|
|
111
|
+
if (isGroupTask(nodes.get(d)!)) stack.push(...(dependents.get(d) ?? []))
|
|
112
|
+
else set.add(d)
|
|
113
|
+
}
|
|
114
|
+
if (set.size === 0) continue
|
|
115
|
+
users.set(id, set)
|
|
116
|
+
for (const u of set) {
|
|
117
|
+
const list = usedBy.get(u)
|
|
118
|
+
if (list === undefined) usedBy.set(u, [id])
|
|
119
|
+
else list.push(id)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const done = (id: string, out: string[]): void => {
|
|
123
|
+
for (const s of usedBy.get(id) ?? []) {
|
|
124
|
+
const left = users.get(s)
|
|
125
|
+
if (left === undefined || !left.delete(id) || left.size > 0) continue
|
|
126
|
+
users.delete(s)
|
|
127
|
+
out.push(s)
|
|
128
|
+
done(s, out)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return (id) => {
|
|
132
|
+
if (usedBy.size === 0) return []
|
|
133
|
+
const n = nodes.get(id)
|
|
134
|
+
// A server finishes when it is ready, still serving its users.
|
|
135
|
+
if (n === undefined || isServer(n)) return []
|
|
136
|
+
const out: string[] = []
|
|
137
|
+
done(id, out)
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The servers that use each server, directly or through groups. A server
|
|
144
|
+
* stops only once these have: one may still talk to it while it shuts
|
|
145
|
+
* down (a flush to its database), and both stopped at once took the
|
|
146
|
+
* database away under that handler.
|
|
147
|
+
*/
|
|
148
|
+
export function serverUsers(
|
|
149
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
150
|
+
): ReadonlyMap<string, readonly string[]> {
|
|
151
|
+
const isServer = (n: TaskNode | undefined): boolean => n?.config.exec?.persistent !== undefined
|
|
152
|
+
const users = new Map<string, string[]>()
|
|
153
|
+
for (const [id, n] of nodes) {
|
|
154
|
+
if (!isServer(n)) continue
|
|
155
|
+
const stack = [...n.deps]
|
|
156
|
+
const seen = new Set<string>()
|
|
157
|
+
while (stack.length > 0) {
|
|
158
|
+
const d = stack.pop()!
|
|
159
|
+
if (seen.has(d)) continue
|
|
160
|
+
seen.add(d)
|
|
161
|
+
const dn = nodes.get(d)
|
|
162
|
+
if (dn !== undefined && isGroupTask(dn)) stack.push(...dn.deps)
|
|
163
|
+
else if (isServer(dn)) {
|
|
164
|
+
const list = users.get(d)
|
|
165
|
+
if (list === undefined) users.set(d, [id])
|
|
166
|
+
else list.push(id)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return users
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Stop one server as `shutdownPersistent` stops the rest: SIGTERM, then SIGKILL past the grace. */
|
|
174
|
+
export async function stopServer(
|
|
175
|
+
child: Child,
|
|
176
|
+
graceMs: number = killGraceMs(PERSISTENT_SHUTDOWN_GRACE_MS),
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
if (hasEnded(child)) return
|
|
179
|
+
const letGo = holdGroups([child])
|
|
180
|
+
try {
|
|
181
|
+
killTree(child, 'SIGTERM')
|
|
182
|
+
for (const c of await untilGroupsGone([child], graceMs)) killTree(c, 'SIGKILL')
|
|
183
|
+
await child.exited
|
|
184
|
+
} finally {
|
|
185
|
+
letGo()
|
|
66
186
|
}
|
|
67
|
-
return out
|
|
68
187
|
}
|
|
69
188
|
|
|
70
189
|
/**
|
|
@@ -81,6 +200,7 @@ export async function shutdownPersistent(
|
|
|
81
200
|
registry: ReadonlyMap<string, Child>,
|
|
82
201
|
keepAlive: readonly Child[],
|
|
83
202
|
graceMs: number = killGraceMs(PERSISTENT_SHUTDOWN_GRACE_MS),
|
|
203
|
+
usersOf: ReadonlyMap<string, readonly string[]> = new Map(),
|
|
84
204
|
): Promise<CrashedPersistent[]> {
|
|
85
205
|
const kept = new Set(keepAlive)
|
|
86
206
|
const crashed: CrashedPersistent[] = []
|
|
@@ -88,13 +208,30 @@ export async function shutdownPersistent(
|
|
|
88
208
|
if (kept.has(child) || !hasEnded(child) || child.exitCode === 0) continue
|
|
89
209
|
crashed.push({ id, code: child.exitCode ?? child.signalCode ?? 'unknown' })
|
|
90
210
|
}
|
|
91
|
-
const dying = [...registry
|
|
211
|
+
const dying = [...registry].filter(([, c]) => !kept.has(c))
|
|
92
212
|
if (dying.length === 0) return crashed
|
|
93
|
-
const letGo = holdGroups(dying)
|
|
213
|
+
const letGo = holdGroups(dying.map(([, c]) => c))
|
|
214
|
+
// Each after the servers using it (`serverUsers`); the rest at once.
|
|
215
|
+
const stops = new Map<string, Promise<void>>()
|
|
216
|
+
const stop = (id: string, child: Child): Promise<void> => {
|
|
217
|
+
const known = stops.get(id)
|
|
218
|
+
if (known !== undefined) return known
|
|
219
|
+
const users: Promise<void>[] = []
|
|
220
|
+
for (const u of usersOf.get(id) ?? []) {
|
|
221
|
+
const c = registry.get(u)
|
|
222
|
+
if (c !== undefined && !kept.has(c)) users.push(stop(u, c))
|
|
223
|
+
}
|
|
224
|
+
const own = async (): Promise<void> => {
|
|
225
|
+
killTree(child, 'SIGTERM')
|
|
226
|
+
for (const c of await untilGroupsGone([child], graceMs)) killTree(c, 'SIGKILL')
|
|
227
|
+
await child.exited
|
|
228
|
+
}
|
|
229
|
+
const p = users.length === 0 ? own() : Promise.allSettled(users).then(own)
|
|
230
|
+
stops.set(id, p)
|
|
231
|
+
return p
|
|
232
|
+
}
|
|
94
233
|
try {
|
|
95
|
-
|
|
96
|
-
for (const child of await untilGroupsGone(dying, graceMs)) killTree(child, 'SIGKILL')
|
|
97
|
-
await Promise.allSettled(dying.map((c) => c.exited))
|
|
234
|
+
await Promise.allSettled(dying.map(([id, c]) => stop(id, c)))
|
|
98
235
|
} finally {
|
|
99
236
|
letGo()
|
|
100
237
|
}
|
|
@@ -279,9 +416,9 @@ export async function disposeServers(args: {
|
|
|
279
416
|
(c) => hasEnded(c) && c.exitCode !== 0 && !keepAlive.children.includes(c),
|
|
280
417
|
)
|
|
281
418
|
if (failedElsewhere && !args.hold && !args.continueAlways) keepAlive = { nodes: [], children: [] }
|
|
282
|
-
const crashed = (
|
|
283
|
-
|
|
284
|
-
)
|
|
419
|
+
const crashed = (
|
|
420
|
+
await shutdownPersistent(registry, keepAlive.children, undefined, serverUsers(args.nodes))
|
|
421
|
+
).filter((c) => endedBeforeStop?.has(registry.get(c.id)!) ?? true)
|
|
285
422
|
for (const c of crashed) failServer(outcomes, c.id, c.code)
|
|
286
423
|
keepAlive.nodes.forEach((n, i) => {
|
|
287
424
|
const child = keepAlive.children[i]!
|
|
@@ -2,8 +2,9 @@
|
|
|
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
4
|
// depends on a persistent task no `servers` executor carries, is sandboxed, says `exec.remote: false`, or
|
|
5
|
-
// folds a runtime probe into its key;
|
|
6
|
-
//
|
|
5
|
+
// folds a runtime probe into its key; a sandboxed task goes to no executor
|
|
6
|
+
// that does not declare `sandbox`, here or remote; an `exec.interactive`
|
|
7
|
+
// task goes to the local floor directly, as only it can hand over this terminal;
|
|
7
8
|
// everything else asks the executors in declaration order, and the local
|
|
8
9
|
// floor takes what nothing claimed. An `exclusive` executor lifts every
|
|
9
10
|
// pin and leaves the floor nothing: a task that would land here refuses.
|
|
@@ -204,6 +205,13 @@ export interface Placements {
|
|
|
204
205
|
* did before the field existed.
|
|
205
206
|
*/
|
|
206
207
|
remoteOnlyNoop: Set<string>
|
|
208
|
+
/**
|
|
209
|
+
* Tasks that depend on a `remoteOnlyNoop` one, directly or through
|
|
210
|
+
* others: each saves no entry. `remote` is not in the key, so the no-op's
|
|
211
|
+
* key is the one a real run of it has, and a dependant's key folding it
|
|
212
|
+
* would file output built without its outputs where a run over them looks.
|
|
213
|
+
*/
|
|
214
|
+
belowNoop: Set<string>
|
|
207
215
|
/** `'only'` tasks a remote executor DID take — executed remotely, outputs stay remote. */
|
|
208
216
|
remoteOnly: Set<string>
|
|
209
217
|
/** Each task placed on a `servers` executor → the persistent tasks it starts there. */
|
|
@@ -223,8 +231,9 @@ export interface Placements {
|
|
|
223
231
|
}
|
|
224
232
|
|
|
225
233
|
/**
|
|
226
|
-
*
|
|
227
|
-
* executor.
|
|
234
|
+
* Whether a task writes IN PLACE — everything not placed on a remote
|
|
235
|
+
* executor. A predicate, not a set: a set held an entry per task of the
|
|
236
|
+
* run for its whole length. `resolveDownloadModes` reads it as "the outputs are already
|
|
228
237
|
* here", so a task wrongly left out is one vx thinks it must fetch from a
|
|
229
238
|
* CAS that never held it.
|
|
230
239
|
*
|
|
@@ -234,11 +243,27 @@ export interface Placements {
|
|
|
234
243
|
* remote", never "declared local"; asking the latter drops every task on
|
|
235
244
|
* the floor executor, i.e. every task in a workspace with no executor
|
|
236
245
|
* plugin. It lives here, shared, because `run()` and `--dry` each built
|
|
237
|
-
* this
|
|
246
|
+
* this inline from the same predicate and a second copy is how the
|
|
238
247
|
* plan and the run would disagree about what comes home (441, 442, 445).
|
|
239
248
|
*/
|
|
240
|
-
export function locallyPlaced(placements: Placements
|
|
241
|
-
return
|
|
249
|
+
export function locallyPlaced(placements: Placements): (id: string) => boolean {
|
|
250
|
+
return (id) => placements.executors.get(id)?.remote !== true
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Why `e`, which runs every task, takes none of this one's: no platform or worker type of it fits. */
|
|
254
|
+
function unrouted(
|
|
255
|
+
e: TaskExecutor,
|
|
256
|
+
named: string | undefined,
|
|
257
|
+
wants: readonly string[] | undefined,
|
|
258
|
+
): string {
|
|
259
|
+
const types = e.workers
|
|
260
|
+
if (types !== undefined && named !== undefined) {
|
|
261
|
+
const type = types.find(([n]) => n === named)
|
|
262
|
+
if (type === undefined)
|
|
263
|
+
return `it has no worker type '${named}' that exec.worker names (types: ${types.map(([n]) => n).join(', ')})`
|
|
264
|
+
return `its worker type '${named}' runs ${type[1].join(', ')}, and exec.platforms asks for ${wants!.join(', ')}`
|
|
265
|
+
}
|
|
266
|
+
return `it offers ${e.platforms?.join(', ') ?? 'no platform'}, and exec.platforms asks for ${wants!.join(', ')}`
|
|
242
267
|
}
|
|
243
268
|
|
|
244
269
|
export function placeTasks(
|
|
@@ -267,6 +292,7 @@ export function placeTasks(
|
|
|
267
292
|
const placements: Placements = {
|
|
268
293
|
executors: new Map(),
|
|
269
294
|
remoteOnlyNoop: new Set(),
|
|
295
|
+
belowNoop: new Set(),
|
|
270
296
|
remoteOnly: new Set(),
|
|
271
297
|
servers: new Map(),
|
|
272
298
|
carried: new Set(),
|
|
@@ -290,12 +316,14 @@ export function placeTasks(
|
|
|
290
316
|
if (executors.length === 1 && executors[0] === floor) {
|
|
291
317
|
for (const node of nodes.values()) {
|
|
292
318
|
delete node.platform
|
|
319
|
+
delete node.worker
|
|
293
320
|
if (isGroupTask(node)) continue
|
|
294
321
|
here(node)
|
|
295
322
|
if (node.config.exec?.persistent !== undefined) continue
|
|
296
323
|
placements.executors.set(node.id, floor)
|
|
297
324
|
if (node.config.exec?.remote === 'only') placements.remoteOnlyNoop.add(node.id)
|
|
298
325
|
}
|
|
326
|
+
markBelowNoop(nodes, placements)
|
|
299
327
|
return placements
|
|
300
328
|
}
|
|
301
329
|
const pinned = pinsOf({})
|
|
@@ -318,6 +346,7 @@ export function placeTasks(
|
|
|
318
346
|
refuse(node.id, 'it starts a server only beside a task that needs it')
|
|
319
347
|
for (const node of nodes.values()) {
|
|
320
348
|
delete node.platform
|
|
349
|
+
delete node.worker
|
|
321
350
|
if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
|
|
322
351
|
if (node.config.exec?.interactive === true && sole === undefined) {
|
|
323
352
|
placements.executors.set(node.id, floor)
|
|
@@ -327,8 +356,18 @@ export function placeTasks(
|
|
|
327
356
|
continue
|
|
328
357
|
}
|
|
329
358
|
const wants: readonly string[] | undefined = node.config.exec?.platforms
|
|
359
|
+
const sandboxed = node.config.exec?.sandbox !== undefined
|
|
360
|
+
const named = node.config.exec?.worker
|
|
361
|
+
const fits = (ps: readonly string[]): boolean =>
|
|
362
|
+
wants === undefined || ps.some((p) => wants.includes(p))
|
|
363
|
+
/** Its worker type on `e`, which names types: `exec.worker`'s, else the first that fits. */
|
|
364
|
+
const typeOn = (e: TaskExecutor): readonly [string, readonly string[]] | undefined =>
|
|
365
|
+
e.workers?.find(([n, ps]) => (named === undefined || n === named) && fits(ps))
|
|
366
|
+
// An executor that names no types ignores `exec.worker`, as this machine does.
|
|
330
367
|
const routes = (e: TaskExecutor): boolean =>
|
|
331
|
-
|
|
368
|
+
e.workers !== undefined
|
|
369
|
+
? typeOn(e) !== undefined
|
|
370
|
+
: wants === undefined || e.platforms?.some((p) => wants.includes(p)) === true
|
|
332
371
|
const executor = selectExecutor(
|
|
333
372
|
executors,
|
|
334
373
|
{
|
|
@@ -337,24 +376,29 @@ export function placeTasks(
|
|
|
337
376
|
projectDir: node.projectDir,
|
|
338
377
|
command: node.config.exec!.command,
|
|
339
378
|
pinnedLocal: pinned.has(node.id),
|
|
340
|
-
cacheable: node.config.cache
|
|
379
|
+
cacheable: node.config.cache === true,
|
|
341
380
|
},
|
|
342
381
|
executorLabel,
|
|
343
382
|
(e) => pinsOf(e).has(node.id) || !routes(e),
|
|
383
|
+
(e) => sandboxed && e.sandbox !== true && e !== floor,
|
|
344
384
|
)
|
|
345
385
|
if (sole !== undefined && executor === floor) {
|
|
346
386
|
refuse(
|
|
347
387
|
node.id,
|
|
348
|
-
|
|
349
|
-
? '
|
|
350
|
-
:
|
|
351
|
-
? 'it
|
|
352
|
-
:
|
|
388
|
+
sandboxed && sole.sandbox !== true
|
|
389
|
+
? 'it does not enforce exec.sandbox'
|
|
390
|
+
: pinsOf(sole).has(node.id)
|
|
391
|
+
? 'its key includes a runtime probe, which it does not check'
|
|
392
|
+
: routes(sole)
|
|
393
|
+
? 'it declined the task'
|
|
394
|
+
: unrouted(sole, named, wants),
|
|
353
395
|
)
|
|
354
396
|
}
|
|
355
397
|
placements.executors.set(node.id, executor)
|
|
398
|
+
const type = routes(executor) ? typeOn(executor) : undefined
|
|
399
|
+
if (type !== undefined) node.worker = type[0]
|
|
356
400
|
if (wants !== undefined) {
|
|
357
|
-
const offered = executor.platforms
|
|
401
|
+
const offered = type?.[1] ?? executor.platforms
|
|
358
402
|
if (offered !== undefined && routes(executor)) {
|
|
359
403
|
node.platform =
|
|
360
404
|
wants.includes(host) && offered.includes(host)
|
|
@@ -386,9 +430,31 @@ export function placeTasks(
|
|
|
386
430
|
if (sole !== undefined) refuse(node.id, 'it starts a server only beside a task that needs it')
|
|
387
431
|
here(node)
|
|
388
432
|
}
|
|
433
|
+
markBelowNoop(nodes, placements)
|
|
389
434
|
return placements
|
|
390
435
|
}
|
|
391
436
|
|
|
437
|
+
/** Fills `belowNoop`: every task that reaches a `remoteOnlyNoop` one. */
|
|
438
|
+
function markBelowNoop(nodes: Map<string, TaskNode>, placements: Placements): void {
|
|
439
|
+
if (placements.remoteOnlyNoop.size === 0) return
|
|
440
|
+
const dependants = new Map<string, string[]>()
|
|
441
|
+
for (const node of nodes.values())
|
|
442
|
+
for (const dep of node.deps) {
|
|
443
|
+
const list = dependants.get(dep)
|
|
444
|
+
if (list === undefined) dependants.set(dep, [node.id])
|
|
445
|
+
else list.push(node.id)
|
|
446
|
+
}
|
|
447
|
+
// An explicit stack: a recursion per edge throws on a deep chain.
|
|
448
|
+
const stack = [...placements.remoteOnlyNoop]
|
|
449
|
+
while (stack.length > 0) {
|
|
450
|
+
for (const id of dependants.get(stack.pop()!) ?? []) {
|
|
451
|
+
if (placements.belowNoop.has(id)) continue
|
|
452
|
+
placements.belowNoop.add(id)
|
|
453
|
+
stack.push(id)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
392
458
|
/**
|
|
393
459
|
* `executorOf` for `planRun`, or nothing. A remote-only task no remote
|
|
394
460
|
* executor takes is labelled `noop` whatever the executor count: the run
|
|
@@ -442,7 +508,7 @@ export async function planExecutorOf(
|
|
|
442
508
|
: resolveDownloadModes({
|
|
443
509
|
nodes: prepared.nodes,
|
|
444
510
|
policy,
|
|
445
|
-
localPlaced: locallyPlaced(placements
|
|
511
|
+
localPlaced: locallyPlaced(placements),
|
|
446
512
|
remoteOnly: placements.remoteOnly,
|
|
447
513
|
})
|
|
448
514
|
return {
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -12,20 +12,27 @@
|
|
|
12
12
|
// side-effect-free pure probes.
|
|
13
13
|
|
|
14
14
|
import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
|
|
15
|
-
import { cachesNothing, FULL_CACHE_POLICY } from '../cache/index.js'
|
|
15
|
+
import { cachesNothing, FULL_CACHE_POLICY, networkConcurrency } from '../cache/index.js'
|
|
16
16
|
import { ranNoCache } from '../report/index.js'
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
idleServers,
|
|
19
|
+
isGroupTask,
|
|
20
|
+
isKeyFailed,
|
|
21
|
+
markKeyFailed,
|
|
22
|
+
runGraph,
|
|
23
|
+
type TaskNode,
|
|
24
|
+
} from '../graph/index.js'
|
|
18
25
|
import type { AffectedReason } from './affected-tasks.js'
|
|
19
26
|
import type { HistoryProvider } from '../history/index.js'
|
|
20
27
|
import { hostPlatform } from '../exec/index.js'
|
|
21
|
-
import { computeGroupKey, computeTaskHash } from './task-hash.js'
|
|
28
|
+
import { computeGroupKey, computeTaskHash, keyOf } from './task-hash.js'
|
|
22
29
|
import { dependedOn, keyedOutcome, keylessServer, keyUpstream } from './upstream.js'
|
|
23
30
|
|
|
24
31
|
export type CacheStatus =
|
|
25
32
|
| 'hit-local' // entry exists in local cache
|
|
26
33
|
| 'hit-remote' // entry exists in the remote layer (would be fetched)
|
|
27
34
|
| 'miss' // caching enabled but no entry — would execute
|
|
28
|
-
| 'no-cache' // task opts out of caching (no `cache`
|
|
35
|
+
| 'no-cache' // task opts out of caching (no `cache: true`) or --no-cache
|
|
29
36
|
| 'group' // no `exec`; just an aggregator
|
|
30
37
|
| 'not-started' // a server every dependant of which restores early: the run never spawns it
|
|
31
38
|
| 'skipped' // `exec.platforms` leave this machine out, or a task it depends on is skipped
|
|
@@ -177,16 +184,18 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
177
184
|
}
|
|
178
185
|
if (isGroupTask(node)) {
|
|
179
186
|
cacheStatusById.set(node.id, 'group')
|
|
180
|
-
return keyedOutcome(node, await computeGroupKey(hashArgs))
|
|
187
|
+
return keyedOutcome(node, await keyOf(computeGroupKey(hashArgs)))
|
|
181
188
|
}
|
|
182
189
|
|
|
183
|
-
const hash = keylessServer(node, depended)
|
|
190
|
+
const hash = keylessServer(node, depended)
|
|
191
|
+
? undefined
|
|
192
|
+
: await keyOf(computeTaskHash(hashArgs))
|
|
184
193
|
|
|
185
194
|
// Prediction keys off READS: a no-read policy re-executes every task,
|
|
186
195
|
// so nothing is probed. Whether that run is a miss or no cache at all
|
|
187
196
|
// is the WRITE axes': `--force` and `--cache-policy=local:w` save what they
|
|
188
197
|
// run, and the plan called them `no-cache`, the label a task with no
|
|
189
|
-
// `cache`
|
|
198
|
+
// `cache: true` gets (item 991). The probe below routes through the
|
|
190
199
|
// policy-aware cache layer, which itself respects read gating.
|
|
191
200
|
const policy = args.cachePolicy ?? FULL_CACHE_POLICY
|
|
192
201
|
const reads = policy.localRead || policy.remoteRead
|
|
@@ -205,15 +214,23 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
205
214
|
})
|
|
206
215
|
if (probed.size > 0) {
|
|
207
216
|
const infos = await args.cache.remoteHasMany?.([...new Set(probed.values())])
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
)
|
|
214
|
-
|
|
215
|
-
|
|
217
|
+
// Asked at once, as a run pulls: in series, a remote that cannot batch
|
|
218
|
+
// cost its round-trip, or its timeout, once per key.
|
|
219
|
+
const pending = [...probed]
|
|
220
|
+
let next = 0
|
|
221
|
+
const one = async (): Promise<void> => {
|
|
222
|
+
while (next < pending.length) {
|
|
223
|
+
const [id, hash] = pending[next++]!
|
|
224
|
+
const where = await args.cache.has(hash)
|
|
225
|
+
cacheStatusById.set(
|
|
226
|
+
id,
|
|
227
|
+
where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local',
|
|
228
|
+
)
|
|
229
|
+
const size = where === 'remote' ? infos?.get(hash)?.size : undefined
|
|
230
|
+
if (size !== undefined) remoteBytesById.set(id, size)
|
|
231
|
+
}
|
|
216
232
|
}
|
|
233
|
+
await Promise.all(Array.from({ length: Math.min(pending.length, networkConcurrency()) }, one))
|
|
217
234
|
}
|
|
218
235
|
|
|
219
236
|
if (args.restorable !== undefined) {
|
|
@@ -234,6 +251,11 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
234
251
|
cacheStatusById.set(id, 'not-started')
|
|
235
252
|
}
|
|
236
253
|
|
|
254
|
+
// A task whose key could not be derived fails the run, so the plan is
|
|
255
|
+
// marked (`isKeyFailed`) and `--dry` exits 1; the scheduler printed the
|
|
256
|
+
// run's own line for each.
|
|
257
|
+
const failed = [...outcomes.values()].some(isKeyFailed)
|
|
258
|
+
const mark = (p: RunPlan): RunPlan => (failed ? markKeyFailed(p) : p)
|
|
237
259
|
const skips = platformSkips(args.nodes, args.otherPlatform)
|
|
238
260
|
const tasks: PlannedTask[] = []
|
|
239
261
|
for (const id of args.nodes.keys()) {
|
|
@@ -268,13 +290,13 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
268
290
|
const p50 = p50s.get(t.node.id)
|
|
269
291
|
if (p50 !== undefined) t.p50Ms = p50
|
|
270
292
|
}
|
|
271
|
-
return { tasks, predicted: predictPlan(tasks), ...downgradeField(args) }
|
|
293
|
+
return mark({ tasks, predicted: predictPlan(tasks), ...downgradeField(args) })
|
|
272
294
|
} catch {
|
|
273
295
|
// Failing open: prediction is a nicety — a broken history read must
|
|
274
296
|
// never break `--dry`.
|
|
275
297
|
}
|
|
276
298
|
}
|
|
277
|
-
return { tasks, ...downgradeField(args) }
|
|
299
|
+
return mark({ tasks, ...downgradeField(args) })
|
|
278
300
|
}
|
|
279
301
|
|
|
280
302
|
/**
|