@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
package/src/cli/upgrade.ts
CHANGED
|
@@ -228,7 +228,7 @@ export function verifiedRelease(
|
|
|
228
228
|
const ours = signatures.filter((s) => s.keyid === key.id)
|
|
229
229
|
if (ours.length === 0) {
|
|
230
230
|
throw new UserError(
|
|
231
|
-
`vx upgrade: ${pkg}@${version} is signed with an npm key this vx does not know (${[...new Set(signatures.map((s) => s.keyid))].join(', ')}) — if npm rotated its signing key, this vx's installer needs updating: install the new release with
|
|
231
|
+
`vx upgrade: ${pkg}@${version} is signed with an npm key this vx does not know (${[...new Set(signatures.map((s) => s.keyid))].join(', ')}) — if npm rotated its signing key, this vx's installer needs updating: install the new release with bun add -g @vzn/vx@latest, or from https://github.com/vznjs/vx/releases. Nothing replaced`,
|
|
232
232
|
)
|
|
233
233
|
}
|
|
234
234
|
const publicKey = createPublicKey({
|
|
@@ -511,7 +511,7 @@ export function managedInstall(execPath: string, cwd: string): ManagedInstall |
|
|
|
511
511
|
const root = p.slice(0, `${p}/`.indexOf('/node_modules/'))
|
|
512
512
|
if (p.includes('/_npx/') || /\/bunx-[^/]*\//.test(p)) {
|
|
513
513
|
throw new UserError(
|
|
514
|
-
`vx upgrade: this vx runs from a package-manager cache (${execPath}) — nothing is installed to upgrade; ask for the version you want instead:
|
|
514
|
+
`vx upgrade: this vx runs from a package-manager cache (${execPath}) — nothing is installed to upgrade; ask for the version you want instead: bunx @vzn/vx@latest`,
|
|
515
515
|
)
|
|
516
516
|
}
|
|
517
517
|
if (p.includes('/.bun/install/global/')) return { manager: 'bun', scope: 'global', cwd: root }
|
|
@@ -733,7 +733,7 @@ export async function upgradeOnlyGlobal(
|
|
|
733
733
|
const install = managedInstall(self, path.dirname(self))
|
|
734
734
|
if (install?.scope === 'project') {
|
|
735
735
|
throw new UserError(
|
|
736
|
-
`vx upgrade -g: no global vx ran — ${self} is the project's own. Install one (
|
|
736
|
+
`vx upgrade -g: no global vx ran — ${self} is the project's own. Install one (bun add -g ${PACKAGE}), or run \`vx upgrade\` to move the project's`,
|
|
737
737
|
)
|
|
738
738
|
}
|
|
739
739
|
if (install === null) await upgradeBinary(self, version, current, 'global ')
|
|
@@ -771,8 +771,7 @@ export async function upgradeCmd(argv: readonly string[]): Promise<number> {
|
|
|
771
771
|
}
|
|
772
772
|
if (!isCompiledBinary()) {
|
|
773
773
|
throw new UserError(
|
|
774
|
-
'vx upgrade: only the compiled binary upgrades itself; this vx runs from source — use git pull
|
|
775
|
-
'(npm installs update with: npm install -g @vzn/vx@latest)',
|
|
774
|
+
'vx upgrade: only the compiled binary upgrades itself; this vx runs from source — use git pull',
|
|
776
775
|
)
|
|
777
776
|
}
|
|
778
777
|
const dest = process.execPath
|
package/src/exec/executor.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// request here; an executor decides WHERE/HOW the process runs. Core's own
|
|
4
4
|
// `local-executor.ts` is the in-process spawn, and it sits at the TAIL of
|
|
5
5
|
// every executor list — what a plugin executor declines runs here.
|
|
6
|
-
// Persistent tasks (`exec.persistent`) never reach an executor
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// Persistent tasks (`exec.persistent`) never reach an executor as tasks: a
|
|
7
|
+
// worker cannot hand the submitter a listening port. A `servers` executor
|
|
8
|
+
// is handed a task's persistent dependencies instead (`ExecuteRequest.servers`)
|
|
9
|
+
// and starts them where it runs the task; the rest stay on `runPersistent`. An `exec.interactive` task reaches the local one
|
|
9
10
|
// only: no plugin can hand it this terminal.
|
|
10
11
|
//
|
|
11
12
|
// Lives in `exec/` (not `orchestrator/`) so the contract depends only on
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
|
|
15
16
|
import type { CaptureConfig, RunResult } from './runner.js'
|
|
16
17
|
import type { ResolvedSandboxConfig, SandboxViolation } from './sandbox-runtime.js'
|
|
17
|
-
import { UserError } from '../util/index.js'
|
|
18
|
+
import { UserError, type ui } from '../util/index.js'
|
|
18
19
|
|
|
19
20
|
/** Sandbox baselines + the user's resolved sandbox block, when the task is sandboxed. */
|
|
20
21
|
export interface ExecuteSandbox {
|
|
@@ -34,6 +35,12 @@ export interface ExecuteSandbox {
|
|
|
34
35
|
*/
|
|
35
36
|
readonly reportLinked: readonly string[]
|
|
36
37
|
readonly config: ResolvedSandboxConfig
|
|
38
|
+
/**
|
|
39
|
+
* A cached task's: the project directories its key answers for, which
|
|
40
|
+
* an executor that builds the request again where it runs, on that
|
|
41
|
+
* machine's paths and platform, hands `sandboxRequestFor`.
|
|
42
|
+
*/
|
|
43
|
+
readonly keyed?: readonly string[]
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
/** One declared input file, as the cache key saw it. */
|
|
@@ -77,6 +84,18 @@ export interface TaskInputs {
|
|
|
77
84
|
readonly configDigest: string
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
/** A persistent dependency a `servers` executor starts where it runs the task, as `runPersistent` does. */
|
|
88
|
+
export interface ServerSpec {
|
|
89
|
+
readonly taskId: string
|
|
90
|
+
readonly command: string
|
|
91
|
+
readonly cwd: string
|
|
92
|
+
readonly env: NodeJS.ProcessEnv
|
|
93
|
+
/** `exec.persistent.readyWhen`: ready at the first output line it matches; absent, at spawn. */
|
|
94
|
+
readonly readyWhen?: string
|
|
95
|
+
/** Bounds the readiness wait. */
|
|
96
|
+
readonly timeoutMs?: number
|
|
97
|
+
}
|
|
98
|
+
|
|
80
99
|
export interface ExecuteRequest {
|
|
81
100
|
/** `${project}#${task}` — for executors that route or log by task. */
|
|
82
101
|
readonly taskId: string
|
|
@@ -162,6 +181,12 @@ export interface ExecuteRequest {
|
|
|
162
181
|
* core's local executor is ever handed such a task (placement.ts).
|
|
163
182
|
*/
|
|
164
183
|
readonly terminal?: true
|
|
184
|
+
/**
|
|
185
|
+
* The persistent tasks it depends on, through groups, for a `servers`
|
|
186
|
+
* executor: each started where the command runs, ready before it, and
|
|
187
|
+
* shared by the tasks that run there. Absent for any other executor.
|
|
188
|
+
*/
|
|
189
|
+
readonly servers?: readonly ServerSpec[]
|
|
165
190
|
}
|
|
166
191
|
|
|
167
192
|
export interface ExecuteResult extends RunResult {
|
|
@@ -180,9 +205,16 @@ export interface ExecuteResult extends RunResult {
|
|
|
180
205
|
* Executor-reported placement label — which machine ran the command
|
|
181
206
|
* (a REAPI worker id, a pool member). Absent = this host. Rides
|
|
182
207
|
* `TaskOutcome.where` into telemetry so a task can be attributed to
|
|
183
|
-
* its worker
|
|
208
|
+
* its worker, onto its row and frame (`<id>@<where>`), and into the
|
|
209
|
+
* run history (v44).
|
|
184
210
|
*/
|
|
185
211
|
readonly where?: string
|
|
212
|
+
/**
|
|
213
|
+
* The cores and memory of the machine `where` names, as it reports
|
|
214
|
+
* them: the run history keeps them, so a task that used all of one
|
|
215
|
+
* reads as wanting a bigger one (`TaskResources.saturated*`).
|
|
216
|
+
*/
|
|
217
|
+
readonly machine?: { readonly cores: number; readonly memoryBytes: number }
|
|
186
218
|
}
|
|
187
219
|
|
|
188
220
|
/** What an executor sees when a task is PLACED — once per task, before scheduling. */
|
|
@@ -193,9 +225,10 @@ export interface TaskPlacement {
|
|
|
193
225
|
readonly command: string
|
|
194
226
|
/**
|
|
195
227
|
* Must run on this machine: the task is persistent, depends (transitively)
|
|
196
|
-
* on a persistent or sandboxed
|
|
228
|
+
* on a persistent task this executor does not carry or a sandboxed one, is sandboxed itself, declares
|
|
197
229
|
* `exec.remote: false`, or folds a runtime probe into its key (this
|
|
198
|
-
* machine's `node -v`)
|
|
230
|
+
* machine's `node -v`) and this executor is not `runtime`. A `remote`
|
|
231
|
+
* executor is never offered such a task.
|
|
199
232
|
*/
|
|
200
233
|
readonly pinnedLocal: boolean
|
|
201
234
|
/** Declares `cache` — the only tasks whose input set is described, and so the only ones that can ship. */
|
|
@@ -207,6 +240,29 @@ export interface TaskExecutor {
|
|
|
207
240
|
readonly name: string
|
|
208
241
|
/** Runs the command somewhere else. Never offered a `pinnedLocal` task. */
|
|
209
242
|
readonly remote?: boolean
|
|
243
|
+
/**
|
|
244
|
+
* A `remote` executor that enforces `ExecuteRequest.sandbox` where it
|
|
245
|
+
* runs the command, as the local one does. It is offered sandboxed tasks
|
|
246
|
+
* and their dependants; the request's violations come back on the result.
|
|
247
|
+
*/
|
|
248
|
+
readonly sandbox?: boolean
|
|
249
|
+
/**
|
|
250
|
+
* A `remote` executor that starts a task's persistent dependencies where
|
|
251
|
+
* it runs the task (`ExecuteRequest.servers`). It is offered the
|
|
252
|
+
* dependants of a persistent task with no `exec.sandbox` and no
|
|
253
|
+
* `exec.remote: false`; that task then runs here only for a dependant
|
|
254
|
+
* placed here (owner, 2026-10-22: nothing runs on a distributed run's
|
|
255
|
+
* orchestrator).
|
|
256
|
+
*/
|
|
257
|
+
readonly servers?: boolean
|
|
258
|
+
/**
|
|
259
|
+
* A `remote` executor that answers a task's `inputs.runtime` and
|
|
260
|
+
* `workspaceRuntime` probes where it runs the task (`answerRuntime`) and
|
|
261
|
+
* fails it when one answers other than `TaskInputs` says this machine
|
|
262
|
+
* did. It is offered tasks whose key folds a probe, which otherwise run
|
|
263
|
+
* here: their key holds this machine's answer (C-2).
|
|
264
|
+
*/
|
|
265
|
+
readonly runtime?: boolean
|
|
210
266
|
/**
|
|
211
267
|
* How many tasks this executor runs at once. Its tasks then occupy a pool
|
|
212
268
|
* of this size instead of the local worker slots, so a remote pool is not
|
|
@@ -230,9 +286,51 @@ export interface TaskExecutor {
|
|
|
230
286
|
* for it.
|
|
231
287
|
*/
|
|
232
288
|
demand?(remaining: ReadonlySet<string>): void
|
|
289
|
+
/**
|
|
290
|
+
* Rows the run's footer shows for this executor, `[label, text]`, the
|
|
291
|
+
* text plain or a `ui` inline (a tone colors it): what only it knows, such as the machines it ran tasks on. Asked on every
|
|
292
|
+
* frame of the live footer and once for the final one, so it is cheap;
|
|
293
|
+
* a throw shows nothing.
|
|
294
|
+
*/
|
|
295
|
+
footer?(): readonly (readonly [label: string, text: ui.Inline])[]
|
|
296
|
+
/**
|
|
297
|
+
* The machines it ran the run's tasks on (`small#1`, `local`), for the
|
|
298
|
+
* run summary telemetry sinks read; asked once at the end, only when a
|
|
299
|
+
* sink is on. A throw reports none.
|
|
300
|
+
*/
|
|
301
|
+
machines?(): readonly MachineReport[]
|
|
302
|
+
/**
|
|
303
|
+
* Where a task it was handed runs (`small#2`), for the live row; asked
|
|
304
|
+
* per frame. `null` while it holds the task for a machine to begin it:
|
|
305
|
+
* the row says `waiting`, and its clock starts when a machine begins it.
|
|
306
|
+
*/
|
|
307
|
+
running?(taskId: string): string | null | undefined
|
|
233
308
|
execute(req: ExecuteRequest): Promise<ExecuteResult>
|
|
234
309
|
}
|
|
235
310
|
|
|
311
|
+
/** One machine an executor ran tasks on, as the run summary reports it. */
|
|
312
|
+
export interface MachineReport {
|
|
313
|
+
/** As task rows name it (`small#1`). */
|
|
314
|
+
name: string
|
|
315
|
+
/** `online`, or `gone` for one that left before the run ended. */
|
|
316
|
+
status: string
|
|
317
|
+
cores: number
|
|
318
|
+
memoryBytes: number
|
|
319
|
+
done: number
|
|
320
|
+
failed: number
|
|
321
|
+
onlineMs: number
|
|
322
|
+
/** Time with at least one task running. */
|
|
323
|
+
busyMs: number
|
|
324
|
+
/** Cores in use at once, at the most and on average over its time online. */
|
|
325
|
+
cpuPeak: number
|
|
326
|
+
cpuAvg: number
|
|
327
|
+
/** Bytes in use at once, at the most and on average. */
|
|
328
|
+
memoryPeakBytes: number
|
|
329
|
+
memoryAvgBytes: number
|
|
330
|
+
/** Core-time its tasks used over the core-time it had, 0 to 1. */
|
|
331
|
+
utilization: number
|
|
332
|
+
}
|
|
333
|
+
|
|
236
334
|
/**
|
|
237
335
|
* What a remote executor rejects with when it gives a task back: core runs
|
|
238
336
|
* the same request on the local floor and says `reason` once (a remote that
|
|
@@ -307,13 +405,18 @@ export function selectExecutor(
|
|
|
307
405
|
executors: readonly TaskExecutor[],
|
|
308
406
|
task: TaskPlacement,
|
|
309
407
|
label: (executor: TaskExecutor) => string = (e) => `executor '${e.name}'`,
|
|
408
|
+
/** `task.pinnedLocal` as `executor` sees it: one that enforces the sandbox or carries servers lifts some pins. */
|
|
409
|
+
pinnedFor: (executor: TaskExecutor) => boolean = () => task.pinnedLocal,
|
|
310
410
|
): TaskExecutor {
|
|
311
411
|
for (const executor of executors) {
|
|
312
|
-
|
|
412
|
+
const pinned = pinnedFor(executor)
|
|
413
|
+
if (pinned && executor.remote === true) continue
|
|
313
414
|
if (executor.accepts === undefined) return executor
|
|
314
415
|
let taken: unknown
|
|
315
416
|
try {
|
|
316
|
-
taken = executor.accepts(
|
|
417
|
+
taken = executor.accepts(
|
|
418
|
+
pinned === task.pinnedLocal ? task : { ...task, pinnedLocal: pinned },
|
|
419
|
+
)
|
|
317
420
|
// An `async accepts` answers a Promise: truthy, so the executor took
|
|
318
421
|
// every task, and a rejection went unheard (the shape of H-16's admit).
|
|
319
422
|
if (taken instanceof Promise) {
|
package/src/exec/index.ts
CHANGED
package/src/exec/proc-sample.ts
CHANGED
|
@@ -19,7 +19,10 @@ export interface TreeUsage {
|
|
|
19
19
|
|
|
20
20
|
interface Proc {
|
|
21
21
|
ppid: number
|
|
22
|
+
/** Its own CPU and, on Linux, what its reaped children used (`cutime`, `cstime`). */
|
|
22
23
|
cpuMs: number
|
|
24
|
+
/** The executable's name; Linux only. */
|
|
25
|
+
comm?: string
|
|
23
26
|
/** Resident KiB when the table carries it; Linux reads it per tree member. */
|
|
24
27
|
rssKiB?: number
|
|
25
28
|
}
|
|
@@ -32,7 +35,11 @@ interface Proc {
|
|
|
32
35
|
const TICK_MS = 10
|
|
33
36
|
|
|
34
37
|
/** Each root's tree usage; a root no longer alive has no entry. */
|
|
35
|
-
export async function sampleTrees(
|
|
38
|
+
export async function sampleTrees(
|
|
39
|
+
roots: readonly number[],
|
|
40
|
+
/** Members not counted (their children still are): the sandbox's own tracer. */
|
|
41
|
+
skip?: ReadonlySet<string>,
|
|
42
|
+
): Promise<Map<number, TreeUsage>> {
|
|
36
43
|
const out = new Map<number, TreeUsage>()
|
|
37
44
|
if (roots.length === 0) return out
|
|
38
45
|
const table = process.platform === 'linux' ? await linuxTable() : await psTable()
|
|
@@ -49,7 +56,8 @@ export async function sampleTrees(roots: readonly number[]): Promise<Map<number,
|
|
|
49
56
|
const stack = [root]
|
|
50
57
|
while (stack.length > 0) {
|
|
51
58
|
const pid = stack.pop()!
|
|
52
|
-
|
|
59
|
+
const comm = table.get(pid)!.comm
|
|
60
|
+
if (skip === undefined || comm === undefined || !skip.has(comm)) tree.push(pid)
|
|
53
61
|
for (const c of children.get(pid) ?? []) stack.push(c)
|
|
54
62
|
}
|
|
55
63
|
let cpuMs = 0
|
|
@@ -86,12 +94,16 @@ async function linuxTable(): Promise<Map<number, Proc> | undefined> {
|
|
|
86
94
|
}
|
|
87
95
|
// `<pid> (<comm>) <state> <ppid> …` — comm may hold spaces and parens,
|
|
88
96
|
// so the fields start after the LAST ')': state is [0], ppid [1],
|
|
89
|
-
// utime [11], stime [12].
|
|
90
|
-
|
|
97
|
+
// utime [11], stime [12], cutime [13], cstime [14]. The reaped
|
|
98
|
+
// children's share keeps a tree's sum from dropping when a member
|
|
99
|
+
// exits: what a task's short-lived processes used stays counted.
|
|
100
|
+
const close = stat.lastIndexOf(')')
|
|
101
|
+
const f = stat.slice(close + 2).split(' ')
|
|
91
102
|
if (f[0] === 'Z' || f[0] === 'X') return
|
|
92
103
|
table.set(Number(entry), {
|
|
93
104
|
ppid: Number(f[1]),
|
|
94
|
-
cpuMs: (Number(f[11]) + Number(f[12])) * TICK_MS,
|
|
105
|
+
cpuMs: (Number(f[11]) + Number(f[12]) + Number(f[13]) + Number(f[14])) * TICK_MS,
|
|
106
|
+
comm: stat.slice(stat.indexOf('(') + 1, close),
|
|
95
107
|
})
|
|
96
108
|
}),
|
|
97
109
|
)
|
|
@@ -153,3 +165,53 @@ export function psTimeMs(s: string): number | undefined {
|
|
|
153
165
|
for (const n of parts) seconds = seconds * 60 + n
|
|
154
166
|
return Math.round((days * 86_400 + seconds) * 1000)
|
|
155
167
|
}
|
|
168
|
+
|
|
169
|
+
/** How often a watched tree is looked at; one timer serves every tree. */
|
|
170
|
+
const WATCH_EVERY_MS = 250
|
|
171
|
+
|
|
172
|
+
const watched = new Map<number, TreeUsage & { looks: number }>()
|
|
173
|
+
let watchTimer: ReturnType<typeof setInterval> | undefined
|
|
174
|
+
let looking = false
|
|
175
|
+
const SKIP = new Set(['strace'])
|
|
176
|
+
|
|
177
|
+
async function look(): Promise<void> {
|
|
178
|
+
if (looking) return
|
|
179
|
+
looking = true
|
|
180
|
+
try {
|
|
181
|
+
const usage = await sampleTrees([...watched.keys()], SKIP)
|
|
182
|
+
for (const [pid, u] of usage) {
|
|
183
|
+
const w = watched.get(pid)
|
|
184
|
+
if (w === undefined) continue
|
|
185
|
+
w.cpuMs = Math.max(w.cpuMs, u.cpuMs)
|
|
186
|
+
w.rssBytes = Math.max(w.rssBytes, u.rssBytes)
|
|
187
|
+
w.looks++
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
// A failed look is a missing point.
|
|
191
|
+
} finally {
|
|
192
|
+
looking = false
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Watch a running tree until `stop`, which answers the most CPU and the
|
|
198
|
+
* peak resident memory any look saw; none when no look landed (a task
|
|
199
|
+
* shorter than one interval). The sandbox's answer on Linux, where the
|
|
200
|
+
* wait never sees what bwrap's pid namespace used.
|
|
201
|
+
*/
|
|
202
|
+
export function watchTree(pid: number): { stop(): TreeUsage | undefined } {
|
|
203
|
+
const w = { cpuMs: 0, rssBytes: 0, looks: 0 }
|
|
204
|
+
watched.set(pid, w)
|
|
205
|
+
watchTimer ??= setInterval(() => void look(), WATCH_EVERY_MS)
|
|
206
|
+
watchTimer.unref?.()
|
|
207
|
+
return {
|
|
208
|
+
stop() {
|
|
209
|
+
if (watched.get(pid) === w) watched.delete(pid)
|
|
210
|
+
if (watched.size === 0 && watchTimer !== undefined) {
|
|
211
|
+
clearInterval(watchTimer)
|
|
212
|
+
watchTimer = undefined
|
|
213
|
+
}
|
|
214
|
+
return w.looks === 0 ? undefined : { cpuMs: w.cpuMs, rssBytes: w.rssBytes }
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/exec/runner.ts
CHANGED
|
@@ -932,6 +932,10 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
932
932
|
new Promise((resolve) => setTimeout(resolve, POST_EXIT_DRAIN_MS).unref?.()),
|
|
933
933
|
])
|
|
934
934
|
}
|
|
935
|
+
// A clean exit is ready: a setup step (migrate, seed, a `docker
|
|
936
|
+
// compose up --wait`) has done its work, and what it started, if
|
|
937
|
+
// anything, is torn down with the run (M 2026-10-22).
|
|
938
|
+
if (code === 0) markReady()
|
|
935
939
|
if (readyAt === undefined && gaveUpAt === undefined) {
|
|
936
940
|
rejectReady(
|
|
937
941
|
new PersistentReadyError(
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
type CaptureConfig,
|
|
60
60
|
type RunResult,
|
|
61
61
|
} from './runner.js'
|
|
62
|
+
import { watchTree } from './proc-sample.js'
|
|
62
63
|
import {
|
|
63
64
|
BUN_GLOB_WILDCARDS,
|
|
64
65
|
executablePath,
|
|
@@ -2074,6 +2075,7 @@ async function runSandboxedOnce(
|
|
|
2074
2075
|
|
|
2075
2076
|
args.liveChildren?.add(proc)
|
|
2076
2077
|
args.onSpawn?.(proc.pid)
|
|
2078
|
+
const watch = process.platform === 'linux' ? watchTree(proc.pid) : undefined
|
|
2077
2079
|
const timeout = armTimeout(proc, args.timeoutMs)
|
|
2078
2080
|
const armedAt = Date.now()
|
|
2079
2081
|
const ac = new AbortController()
|
|
@@ -2102,6 +2104,7 @@ async function runSandboxedOnce(
|
|
|
2102
2104
|
// aborts at once, otherwise drainOrAbort bounds the post-exit drain.
|
|
2103
2105
|
await proc.exited
|
|
2104
2106
|
const ranMs = Date.now() - armedAt
|
|
2107
|
+
const looked = watch?.stop()
|
|
2105
2108
|
await timeout.settle()
|
|
2106
2109
|
let cut = false
|
|
2107
2110
|
if (timeout.timedOut()) ac.abort()
|
|
@@ -2276,10 +2279,14 @@ async function runSandboxedOnce(
|
|
|
2276
2279
|
// processes used is not folded into bwrap's usage: a 500 ms busy loop
|
|
2277
2280
|
// read 2 ms under it and 510 without the flag, and the peak read was
|
|
2278
2281
|
// vx's own high-water mark, inherited at exec (a sandboxed `true` from a
|
|
2279
|
-
// 300 MB vx: 353 MB; B-3).
|
|
2280
|
-
//
|
|
2281
|
-
// its usage is the task's.
|
|
2282
|
-
...(process.platform
|
|
2282
|
+
// 300 MB vx: 353 MB; B-3). So the tree is looked at while it runs
|
|
2283
|
+
// (`watchTree`); a task over before the first look reports nothing.
|
|
2284
|
+
// macOS's `sandbox-exec` execs the command, so its usage is the task's.
|
|
2285
|
+
...(process.platform !== 'linux'
|
|
2286
|
+
? resourceUsageToCpuRss(proc.resourceUsage())
|
|
2287
|
+
: looked === undefined
|
|
2288
|
+
? {}
|
|
2289
|
+
: { cpuMs: looked.cpuMs, peakRssBytes: looked.rssBytes }),
|
|
2283
2290
|
ranMs,
|
|
2284
2291
|
tracerFailed:
|
|
2285
2292
|
straceLog !== undefined &&
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -98,6 +98,12 @@ export interface TaskOutcome {
|
|
|
98
98
|
/** v11 analytics: CPU time + peak RSS for this task's child process. */
|
|
99
99
|
cpuMs?: number
|
|
100
100
|
peakRssBytes?: number
|
|
101
|
+
/**
|
|
102
|
+
* The most cores one sampling interval burned and the mean resident
|
|
103
|
+
* memory over the looks; only when a sink samples and a look landed.
|
|
104
|
+
*/
|
|
105
|
+
peakCores?: number
|
|
106
|
+
avgRssBytes?: number
|
|
101
107
|
/**
|
|
102
108
|
* A GROUP task's own dependency outcomes — the tasks it stands for.
|
|
103
109
|
* A group has no `exec` and therefore no outputs and no cache entry: its
|
|
@@ -116,11 +122,11 @@ export interface TaskOutcome {
|
|
|
116
122
|
*/
|
|
117
123
|
unkeyed?: true
|
|
118
124
|
/**
|
|
119
|
-
*
|
|
120
|
-
* can derive that key again, so it neither read nor
|
|
121
|
-
* dependant that folds `hash` does neither.
|
|
125
|
+
* The task with no `inputs` this task's key folds (this task, or one
|
|
126
|
+
* upstream): no run can derive that key again, so it neither read nor
|
|
127
|
+
* saved an entry, and a dependant that folds `hash` does neither.
|
|
122
128
|
*/
|
|
123
|
-
|
|
129
|
+
keylessBy?: string
|
|
124
130
|
/**
|
|
125
131
|
* The run's cache policy read and wrote nothing (`--no-cache`,
|
|
126
132
|
* `--cache-policy=local:`): no cache answered for this task, so a run of it is
|
|
@@ -166,8 +172,11 @@ export interface TaskOutcome {
|
|
|
166
172
|
*/
|
|
167
173
|
inputMoved?: string
|
|
168
174
|
/** Executor-reported placement label (`ExecuteResult.where`) — set only
|
|
169
|
-
* when the task ran somewhere other than this host
|
|
175
|
+
* when the task ran somewhere other than this host: telemetry and the task's row. */
|
|
170
176
|
where?: string
|
|
177
|
+
/** The cores and memory of the machine `where` names (`ExecuteResult.machine`). */
|
|
178
|
+
whereCores?: number
|
|
179
|
+
whereMemoryBytes?: number
|
|
171
180
|
/** `'deferred'` when the task's outputs were left in the remote store
|
|
172
181
|
* (`--cache-download=none`) instead of landing on this machine. Absent for
|
|
173
182
|
* every ordinary outcome. Telemetry-only. */
|
|
@@ -344,6 +353,14 @@ export interface ScheduleOptions {
|
|
|
344
353
|
* second cache.get. When undefined/empty, behavior is byte-identical.
|
|
345
354
|
*/
|
|
346
355
|
restoreTier?: ReadonlySet<string>
|
|
356
|
+
/**
|
|
357
|
+
* Servers held back until the caller knows whether their dependants are
|
|
358
|
+
* hits a remote holds. Each resolves to the dependants it has added to
|
|
359
|
+
* `restoreTier` since the run began (the caller's set, read live); a
|
|
360
|
+
* server whose every dependant is then in the tier is idle, as above,
|
|
361
|
+
* and any other is dispatched as usual.
|
|
362
|
+
*/
|
|
363
|
+
heldServers?: ReadonlyMap<string, Promise<readonly string[]>>
|
|
347
364
|
/**
|
|
348
365
|
* Pool assignment for tasks placed on an executor with its own capacity
|
|
349
366
|
* (a remote pool). Such a task occupies one of the pool's `capacity` slots
|
|
@@ -573,6 +590,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
573
590
|
// without an outcome: it is not part of the run, as a pruned task is not.
|
|
574
591
|
const idle = idleServers(nodes, dependents, restoreTier)
|
|
575
592
|
const dormant = new Set<string>()
|
|
593
|
+
// Held servers not yet answered, and those of them that became ready.
|
|
594
|
+
const unanswered = new Set(options.heldServers?.keys() ?? [])
|
|
595
|
+
for (const id of idle) unanswered.delete(id)
|
|
596
|
+
const heldReady = new Set<string>()
|
|
576
597
|
let settledIdle = 0
|
|
577
598
|
const execReady = new ReadyHeap(priority)
|
|
578
599
|
const restoreReady = new ReadyHeap(priority)
|
|
@@ -595,6 +616,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
595
616
|
else continue
|
|
596
617
|
readyAt.set(node.id, startedAt)
|
|
597
618
|
}
|
|
619
|
+
const promoted = new Set<string>()
|
|
598
620
|
|
|
599
621
|
let active = 0
|
|
600
622
|
// Restore-tier work is disk I/O — a confirmed hit's tar extract is ~8
|
|
@@ -613,7 +635,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
613
635
|
serial ? active + activeRestore < 1 : activeRestore < restoreConcurrency
|
|
614
636
|
let resolved = false
|
|
615
637
|
// A refusal's text → the first task that said it. A corrupt cache index
|
|
616
|
-
// reaches every lookup as one UserError; a repeat names the first.
|
|
638
|
+
// reaches every lookup as one UserError; on bare stderr a repeat names the first.
|
|
617
639
|
const refusedBy = new Map<string, string>()
|
|
618
640
|
|
|
619
641
|
// Admission policy over the count limit (a plugin's `admit`). Inactive
|
|
@@ -863,6 +885,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
863
885
|
while (execReady.size > 0) {
|
|
864
886
|
const seq = execReady.peekSeq()
|
|
865
887
|
const id = execReady.pop() as string
|
|
888
|
+
if (unanswered.has(id) && !willSkip(id)) {
|
|
889
|
+
heldReady.add(id)
|
|
890
|
+
continue
|
|
891
|
+
}
|
|
866
892
|
if (willSkip(id) || idle.has(id)) return id
|
|
867
893
|
if (exclusive?.has(id) === true) {
|
|
868
894
|
if (!busy() && (!admitActive || admits(id))) return id
|
|
@@ -1045,8 +1071,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1045
1071
|
const text = isUserError(err)
|
|
1046
1072
|
? message
|
|
1047
1073
|
: `${message} — ${isOutOfFds(err) ? OUT_OF_FDS_HINT : fsRefusalHint(err)}`
|
|
1048
|
-
|
|
1049
|
-
|
|
1074
|
+
// In a task's own output block, in full: that block is read
|
|
1075
|
+
// alone, and "as <task> above" named one printed elsewhere or
|
|
1076
|
+
// not at all (M, 2026-10-22). On bare stderr the first is above.
|
|
1077
|
+
const first = options.onError === undefined ? refusedBy.get(text) : undefined
|
|
1078
|
+
if (!refusedBy.has(text)) refusedBy.set(text, id)
|
|
1050
1079
|
line = `[vx] ${id}: ${first === undefined ? text : `as ${first} above`}\n`
|
|
1051
1080
|
} else {
|
|
1052
1081
|
const named = err instanceof Error && err.name !== 'Error' ? `${err.name}: ` : ''
|
|
@@ -1089,6 +1118,28 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1089
1118
|
}
|
|
1090
1119
|
}
|
|
1091
1120
|
|
|
1121
|
+
// A held server's answer: the dependants it found go on the restore
|
|
1122
|
+
// lane, and the server is idle when they were all of them.
|
|
1123
|
+
const answer = (id: string, found: readonly string[]): void => {
|
|
1124
|
+
if (resolved || !unanswered.delete(id)) return
|
|
1125
|
+
for (const d of found) {
|
|
1126
|
+
if (promoted.has(d) || outcomes.has(d) || !inRestoreTier(d)) continue
|
|
1127
|
+
promoted.add(d)
|
|
1128
|
+
restoreReady.push(d)
|
|
1129
|
+
readyAt.set(d, Date.now())
|
|
1130
|
+
}
|
|
1131
|
+
if (dependents.get(id)?.every((d) => inRestoreTier(d)) === true) idle.add(id)
|
|
1132
|
+
if (heldReady.delete(id)) execReady.push(id)
|
|
1133
|
+
tick()
|
|
1134
|
+
}
|
|
1135
|
+
for (const [id, found] of options.heldServers ?? []) {
|
|
1136
|
+
if (unanswered.has(id))
|
|
1137
|
+
void found.then(
|
|
1138
|
+
(ids) => answer(id, ids),
|
|
1139
|
+
() => answer(id, []),
|
|
1140
|
+
)
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1092
1143
|
tick()
|
|
1093
1144
|
})
|
|
1094
1145
|
}
|