@vzn/vx 0.0.21 → 0.0.22
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 +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +611 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
|
@@ -250,10 +250,12 @@ export function formatSummarySection(
|
|
|
250
250
|
info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
|
|
251
251
|
// The plugin's hand on the run: a task a policy held with a worker
|
|
252
252
|
// free would otherwise have started; without the line nothing says
|
|
253
|
-
// the policy acted.
|
|
253
|
+
// the policy acted. The duration is the waits summed, task-seconds
|
|
254
|
+
// — 85 held tasks read `4783s` beside a 160 s run on TanStack/router
|
|
255
|
+
// (2026-09-15), so the line says it is a sum.
|
|
254
256
|
if (stats.held !== undefined && stats.held.count > 0) {
|
|
255
257
|
info.push(
|
|
256
|
-
`admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'} ${formatDuration(stats.held.sumMs)}`,
|
|
258
|
+
`admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${formatDuration(stats.held.sumMs)} in all`,
|
|
257
259
|
)
|
|
258
260
|
}
|
|
259
261
|
lines.push('', row('info', join(info)), row('time', `${formatDuration(totalMs)}${spread}`))
|
|
@@ -343,6 +345,51 @@ export function formatAbortedSection(outcomes: readonly TaskOutcome[]): string[]
|
|
|
343
345
|
return lines
|
|
344
346
|
}
|
|
345
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Post-summary section naming the tasks that never started, under the
|
|
350
|
+
* failure that blocked them. The footer's "1 skipped" says a task was
|
|
351
|
+
* blocked and nothing about which task or by what; the broad flow prints
|
|
352
|
+
* no row for a skipped task on purpose (a wide red run would drown in
|
|
353
|
+
* them), so this is where the reader learns what the failure cost
|
|
354
|
+
* (item 266). The scheduler records the root of each block on the outcome
|
|
355
|
+
* (`blockedBy`, item 267); a skip without one is fail-fast's. Empty when
|
|
356
|
+
* nothing was skipped.
|
|
357
|
+
*/
|
|
358
|
+
export function formatSkippedSection(outcomes: readonly TaskOutcome[]): string[] {
|
|
359
|
+
// A group (no `exec`) never starts by definition and is not a task the
|
|
360
|
+
// tally counts; listing a blocked one here named three "tasks" the legend
|
|
361
|
+
// beside it counted as none (item 281). Its members' rows say enough.
|
|
362
|
+
const skipped = outcomes.filter((o) => o.status === 'skipped' && !isGroupTask(o.node))
|
|
363
|
+
if (skipped.length === 0) return []
|
|
364
|
+
const byId = new Map(outcomes.map((o) => [o.node.id, o]))
|
|
365
|
+
const causeOf = (o: TaskOutcome): string => {
|
|
366
|
+
if (o.blockedBy === undefined) return 'after the run stopped (fail-fast)'
|
|
367
|
+
const root = byId.get(o.blockedBy)
|
|
368
|
+
return root?.status === 'aborted'
|
|
369
|
+
? `after ${o.blockedBy} was aborted`
|
|
370
|
+
: `after ${o.blockedBy} failed`
|
|
371
|
+
}
|
|
372
|
+
const groups = new Map<string, string[]>()
|
|
373
|
+
for (const o of skipped) {
|
|
374
|
+
const cause = causeOf(o)
|
|
375
|
+
const list = groups.get(cause) ?? []
|
|
376
|
+
list.push(o.node.id)
|
|
377
|
+
groups.set(cause, list)
|
|
378
|
+
}
|
|
379
|
+
const lines = [
|
|
380
|
+
'',
|
|
381
|
+
` Skipped: ${skipped.length} task${skipped.length === 1 ? '' : 's'} never started \u2014 blocked upstream`,
|
|
382
|
+
]
|
|
383
|
+
const NAMES = 8
|
|
384
|
+
for (const [cause, ids] of [...groups].sort((a, b) => b[1].length - a[1].length)) {
|
|
385
|
+
ids.sort()
|
|
386
|
+
const shown = ids.slice(0, NAMES).join(', ')
|
|
387
|
+
const more = ids.length > NAMES ? ` \u2026 +${ids.length - NAMES} more` : ''
|
|
388
|
+
lines.push(` \u2298 ${cause}: ${shown}${more}`)
|
|
389
|
+
}
|
|
390
|
+
return lines
|
|
391
|
+
}
|
|
392
|
+
|
|
346
393
|
/**
|
|
347
394
|
* Post-summary section naming the tasks this run proved flaky: identical
|
|
348
395
|
* inputs, both outcomes on record (or a retry within the run). A red run
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { lstatSync } from 'node:fs'
|
|
1
2
|
import path from 'node:path'
|
|
2
3
|
import type { TaskConfig, CacheConfig } from '../config.js'
|
|
3
|
-
import type { WorkspaceFilesCache } from '../cache/index.js'
|
|
4
|
+
import type { ProjectFilesCache, WorkspaceFilesCache } from '../cache/index.js'
|
|
4
5
|
import {
|
|
5
6
|
type CacheKeyInput,
|
|
6
7
|
type CacheLayer,
|
|
8
|
+
FILE_HASH_RACY_MS,
|
|
7
9
|
resolveInputs,
|
|
8
10
|
type GitFilesCache,
|
|
9
11
|
WORKSPACE_OUTPUT_PREFIX,
|
|
@@ -20,6 +22,9 @@ import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
|
|
|
20
22
|
* project re-reads the same `package.json`; without this, a
|
|
21
23
|
* monorepo with N projects × M tasks per project does N×M reads
|
|
22
24
|
* of the same bytes.
|
|
25
|
+
* - `projectFiles`: keyed by the project and its input DECLARATION.
|
|
26
|
+
* Tasks of one project that declare the same inputs and outputs
|
|
27
|
+
* resolve the same file list; see `ProjectFilesCache`.
|
|
23
28
|
* - `taskConfig`: keyed by the resolved-config object reference.
|
|
24
29
|
* Each task's config is created once at prepareRun time; the
|
|
25
30
|
* JSON.stringify + xxh3 of it is deterministic. WeakMap so
|
|
@@ -34,6 +39,7 @@ export interface HashCache {
|
|
|
34
39
|
runtime: Map<string, Promise<string>>
|
|
35
40
|
workspaceRuntime: Map<string, Promise<string>>
|
|
36
41
|
workspaceFiles: WorkspaceFilesCache
|
|
42
|
+
projectFiles: ProjectFilesCache
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
export function createHashCache(): HashCache {
|
|
@@ -43,6 +49,7 @@ export function createHashCache(): HashCache {
|
|
|
43
49
|
runtime: new Map(),
|
|
44
50
|
workspaceRuntime: new Map(),
|
|
45
51
|
workspaceFiles: new Map(),
|
|
52
|
+
projectFiles: new Map(),
|
|
46
53
|
}
|
|
47
54
|
}
|
|
48
55
|
|
|
@@ -101,6 +108,18 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
|
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
|
|
111
|
+
/**
|
|
112
|
+
* One file the key folded: the digest it folded, and since when (ms since
|
|
113
|
+
* the epoch) that digest is known to be the file's content — the
|
|
114
|
+
* enumeration's start for an index OID (`git status` vouched for it then),
|
|
115
|
+
* the describe's start for a digest hashed from the file.
|
|
116
|
+
*/
|
|
117
|
+
export interface InputFact {
|
|
118
|
+
path: string
|
|
119
|
+
digest: string
|
|
120
|
+
since: number
|
|
121
|
+
}
|
|
122
|
+
|
|
104
123
|
/**
|
|
105
124
|
* The key AND the structured input set behind it, for the executor seam.
|
|
106
125
|
* Miss path only: it re-runs the (memoized) resolution `computeTaskHash`
|
|
@@ -112,7 +131,8 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
|
|
|
112
131
|
*/
|
|
113
132
|
export async function describeTaskInputs(
|
|
114
133
|
args: ComputeHashArgs,
|
|
115
|
-
): Promise<{ hash: string; inputs: TaskInputs }> {
|
|
134
|
+
): Promise<{ hash: string; inputs: TaskInputs; facts: InputFact[]; describedAt: number }> {
|
|
135
|
+
const hashedAt = Date.now()
|
|
116
136
|
const input = await resolveKeyInput(args)
|
|
117
137
|
const hash = await args.cache.key(input)
|
|
118
138
|
const sorted = [...input.inputFiles].sort()
|
|
@@ -123,6 +143,22 @@ export async function describeTaskInputs(
|
|
|
123
143
|
path: relPosix(input.workspaceRoot, f),
|
|
124
144
|
digest: digests[i]!,
|
|
125
145
|
}))
|
|
146
|
+
// The package.json digest is a per-run memo that may predate this
|
|
147
|
+
// describe, so it is dated from the enumeration, the earliest a run
|
|
148
|
+
// learns anything.
|
|
149
|
+
const indexedAt = args.gitFilesCache?.enumeratedAtMs ?? 0
|
|
150
|
+
const facts: InputFact[] = sorted.map((f, i) => ({
|
|
151
|
+
path: f,
|
|
152
|
+
digest: digests[i]!,
|
|
153
|
+
since: input.fileHashes?.has(f) === true ? indexedAt : hashedAt,
|
|
154
|
+
}))
|
|
155
|
+
if (input.projectPackageJsonHash !== '') {
|
|
156
|
+
facts.push({
|
|
157
|
+
path: path.join(args.node.projectDir, 'package.json'),
|
|
158
|
+
digest: input.projectPackageJsonHash,
|
|
159
|
+
since: indexedAt,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
126
162
|
// Groups expanded: this list describes the INPUT ROOT, not the key, and a
|
|
127
163
|
// group produces nothing of its own (see `CacheKeyInput.upstreamGraft`).
|
|
128
164
|
// Sorted by hash so the list — and any action digest derived from it — is
|
|
@@ -143,6 +179,8 @@ export async function describeTaskInputs(
|
|
|
143
179
|
})
|
|
144
180
|
return {
|
|
145
181
|
hash,
|
|
182
|
+
facts,
|
|
183
|
+
describedAt: hashedAt,
|
|
146
184
|
inputs: {
|
|
147
185
|
files,
|
|
148
186
|
env: input.envValues.map(([name, value]) => ({ name, value })),
|
|
@@ -159,6 +197,41 @@ export async function describeTaskInputs(
|
|
|
159
197
|
}
|
|
160
198
|
}
|
|
161
199
|
|
|
200
|
+
/**
|
|
201
|
+
* The first input whose content may no longer be the digest the key folded
|
|
202
|
+
* (item 743), or undefined when every one still is. One `lstat` per file:
|
|
203
|
+
* a file whose ctime is older than its fact (by the racy window, as git
|
|
204
|
+
* judges its index) has not been written since, and one that is not gets
|
|
205
|
+
* hashed again and compared. ctime, because no writer can set it back. A
|
|
206
|
+
* file that is gone has moved. One written at or after `commandFrom` (the
|
|
207
|
+
* describe just before the command) has moved whatever it holds now: an
|
|
208
|
+
* input changed and changed BACK while the command ran matched its digest
|
|
209
|
+
* again, and the entry saved the other content's output under the key (item
|
|
210
|
+
* 1015). A write before it — an upstream's — is judged by content, as before.
|
|
211
|
+
*/
|
|
212
|
+
export async function movedInput(
|
|
213
|
+
facts: readonly InputFact[],
|
|
214
|
+
cache: CacheLayer,
|
|
215
|
+
commandFrom?: number,
|
|
216
|
+
): Promise<string | undefined> {
|
|
217
|
+
const suspects: InputFact[] = []
|
|
218
|
+
for (const f of facts) {
|
|
219
|
+
let ctimeMs: number
|
|
220
|
+
try {
|
|
221
|
+
ctimeMs = lstatSync(f.path).ctimeMs
|
|
222
|
+
} catch {
|
|
223
|
+
return f.path
|
|
224
|
+
}
|
|
225
|
+
if (commandFrom !== undefined && ctimeMs >= commandFrom) return f.path
|
|
226
|
+
if (ctimeMs >= f.since - FILE_HASH_RACY_MS) suspects.push(f)
|
|
227
|
+
}
|
|
228
|
+
for (const f of suspects) {
|
|
229
|
+
const now = await cache.hashFile(f.path).catch(() => undefined)
|
|
230
|
+
if (now !== f.digest) return f.path
|
|
231
|
+
}
|
|
232
|
+
return undefined
|
|
233
|
+
}
|
|
234
|
+
|
|
162
235
|
async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
|
|
163
236
|
const cfg = args.node.config
|
|
164
237
|
const cacheCfg: CacheConfig | undefined = cfg.cache
|
|
@@ -178,6 +251,7 @@ async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
|
|
|
178
251
|
runtimeCache: args.hashCache.runtime,
|
|
179
252
|
workspaceRuntimeCache: args.hashCache.workspaceRuntime,
|
|
180
253
|
workspaceFilesCache: args.hashCache.workspaceFiles,
|
|
254
|
+
projectFilesCache: args.hashCache.projectFiles,
|
|
181
255
|
}
|
|
182
256
|
: {}),
|
|
183
257
|
})
|
|
@@ -251,8 +325,9 @@ async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
|
|
|
251
325
|
* participate because the loader has already baked them into the
|
|
252
326
|
* resolved object before we serialize.
|
|
253
327
|
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
328
|
+
* A loaded config is JSON data: every load path refuses a value JSON
|
|
329
|
+
* cannot carry (workspace/json-data.ts, item 701), so this JSON is the
|
|
330
|
+
* config as vx reads it. The `hashCache.taskConfig` WeakMap is consulted first —
|
|
256
331
|
* each task's config object is created once per run, so a hit there
|
|
257
332
|
* skips the JSON.stringify + xxh3 entirely.
|
|
258
333
|
*/
|
|
@@ -276,9 +351,14 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
|
|
|
276
351
|
* laptop from a worker pool over nothing. (`exec.resources` rode the same
|
|
277
352
|
* rule until 2026-09-12, when reservations left the config for the
|
|
278
353
|
* schedule plugin that learns them; a config never declares them now.) A
|
|
279
|
-
* config that declares no `remote` takes the fast path
|
|
280
|
-
*
|
|
281
|
-
*
|
|
354
|
+
* config that declares no `remote` takes the fast path. That is a
|
|
355
|
+
* SHORTCUT, not the reason the field needed no CACHE_VERSION bump:
|
|
356
|
+
* measured (item 505), the spread path stringifies byte-identically for
|
|
357
|
+
* every config that HAS an `exec`, since a spread keeps an existing key
|
|
358
|
+
* in place. Its one behavioural case is a config with NO `exec`, where
|
|
359
|
+
* the spread would add `exec: {}` — and nothing reaches here that way,
|
|
360
|
+
* proven by throwing on a no-exec config and running the whole suite:
|
|
361
|
+
* a group takes `computeGroupHash` instead. `timeout`/`retries` stay folded — their keys are
|
|
282
362
|
* distinct by design (see the decision log); stripping them retroactively
|
|
283
363
|
* would bump CACHE_VERSION.
|
|
284
364
|
*/
|
|
@@ -79,13 +79,6 @@ interface InFlight {
|
|
|
79
79
|
truncatedHeadChars: number
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/**
|
|
83
|
-
* Bounded per-run capture. `append` keeps a chunk LIST + running char count per
|
|
84
|
-
* task, evicting whole chunks from the head past `TASK_LOG_TAIL_CHARS` — no
|
|
85
|
-
* string concatenation until `drain`, so a cache-hit replay (one big chunk) is
|
|
86
|
-
* one array push, zero copies. `finish` decides retention; `drain` emits the
|
|
87
|
-
* bundle, failures first.
|
|
88
|
-
*/
|
|
89
82
|
/**
|
|
90
83
|
* Per-retained-chunk overhead, in char-equivalents, charged against the RUN
|
|
91
84
|
* budget on top of the characters themselves.
|
|
@@ -110,6 +103,13 @@ function budgetCost(chars: number, chunkCount: number): number {
|
|
|
110
103
|
return chars + chunkCount * CHUNK_OVERHEAD_CHARS
|
|
111
104
|
}
|
|
112
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Bounded per-run capture. `append` keeps a chunk LIST + running char count per
|
|
108
|
+
* task, evicting whole chunks from the head past `TASK_LOG_TAIL_CHARS` — no
|
|
109
|
+
* string concatenation until `drain`, so a cache-hit replay (one big chunk) is
|
|
110
|
+
* one array push, zero copies. `finish` decides retention; `drain` emits the
|
|
111
|
+
* bundle, failures first.
|
|
112
|
+
*/
|
|
113
113
|
export class TaskLogBuffer {
|
|
114
114
|
private readonly inFlight = new Map<string, InFlight>()
|
|
115
115
|
private readonly retained = new Map<string, Retained>()
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
TelemetryContext,
|
|
19
19
|
TelemetrySink,
|
|
20
20
|
} from './telemetry.js'
|
|
21
|
+
import { settleWithin, teardownTimeoutMs } from '../util/index.js'
|
|
21
22
|
import { createTelemetrySource } from './telemetry.js'
|
|
22
23
|
import type { VxPlugin } from './plugin.js'
|
|
23
24
|
|
|
@@ -79,7 +80,18 @@ export async function subscribeTelemetry(
|
|
|
79
80
|
// check sits INSIDE the try so a throwing `wants` getter is caught too.
|
|
80
81
|
let accepted: TelemetrySink[]
|
|
81
82
|
try {
|
|
82
|
-
|
|
83
|
+
// Bounded as flush and teardown are: a hook that never settles held
|
|
84
|
+
// the run before its first task, forever or until the loop drained
|
|
85
|
+
// and Bun exited 0 (item 921).
|
|
86
|
+
let result: Awaited<ReturnType<NonNullable<VxPlugin['telemetry']>>> | undefined
|
|
87
|
+
const ms = teardownTimeoutMs()
|
|
88
|
+
const arrived = await settleWithin(
|
|
89
|
+
Promise.resolve(plugin.telemetry(ctx)).then((r) => {
|
|
90
|
+
result = r
|
|
91
|
+
}),
|
|
92
|
+
ms,
|
|
93
|
+
)
|
|
94
|
+
if (!arrived) throw new Error(`did not initialize within ${ms} ms`)
|
|
83
95
|
const list = result === undefined ? [] : Array.isArray(result) ? result : [result]
|
|
84
96
|
accepted = list.map((sink) => checkSink(sink))
|
|
85
97
|
} catch (err) {
|
|
@@ -94,15 +106,12 @@ export async function subscribeTelemetry(
|
|
|
94
106
|
if (sinks.length === 0) return undefined
|
|
95
107
|
|
|
96
108
|
const source = createTelemetrySource({ sinks, run, warn: (m) => ctx.warn(m) })
|
|
109
|
+
// The bus's unsubscribe is idempotent, so it is the handle's dispose as
|
|
110
|
+
// is (a once-flag here survived item 654).
|
|
97
111
|
const dispose = bus.subscribe(source.subscriber)
|
|
98
|
-
let disposed = false
|
|
99
112
|
return {
|
|
100
113
|
emitSummary: (summary) => source.emitSummary(summary),
|
|
101
114
|
flush: () => source.flush(),
|
|
102
|
-
dispose
|
|
103
|
-
if (disposed) return
|
|
104
|
-
disposed = true
|
|
105
|
-
dispose()
|
|
106
|
-
},
|
|
115
|
+
dispose,
|
|
107
116
|
}
|
|
108
117
|
}
|
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
// what or how tasks run. Contrast `cache`/`executor`, which return objects
|
|
17
17
|
// core calls INTO; those are the behavior capabilities, kept separate.
|
|
18
18
|
|
|
19
|
-
import type { TaskStatus } from '../graph/index.js'
|
|
19
|
+
import type { TaskOutcome, TaskStatus } from '../graph/index.js'
|
|
20
20
|
import { settleWithin, teardownTimeoutMs } from '../util/index.js'
|
|
21
21
|
import type { RunEvent, RunEventSubscriber } from './events.js'
|
|
22
22
|
|
|
23
23
|
/** Bumped when the record shape changes. Readers MUST check `v`. */
|
|
24
|
-
export const TELEMETRY_SCHEMA_VERSION =
|
|
24
|
+
export const TELEMETRY_SCHEMA_VERSION = 3
|
|
25
25
|
|
|
26
26
|
/** Where a task's result came from, derived ONCE in core from the status. */
|
|
27
27
|
export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
|
|
@@ -75,8 +75,6 @@ const PASSES: Record<TaskStatus, boolean> = {
|
|
|
75
75
|
*/
|
|
76
76
|
export const TASK_STATUSES: readonly TaskStatus[] = Object.keys(PASSES) as TaskStatus[]
|
|
77
77
|
|
|
78
|
-
const KNOWN_STATUSES: ReadonlySet<string> = new Set(TASK_STATUSES)
|
|
79
|
-
|
|
80
78
|
/**
|
|
81
79
|
* Did the task pass? A cache hit counts — it produced the same result without
|
|
82
80
|
* spending the time, which is the whole point. `skipped` and `aborted` do NOT:
|
|
@@ -97,7 +95,8 @@ export function isPassStatus(status: string): boolean {
|
|
|
97
95
|
* cannot disagree about what a hit is. Unknown strings read as not-a-hit.
|
|
98
96
|
*/
|
|
99
97
|
export function isCacheHit(status: string): boolean {
|
|
100
|
-
|
|
98
|
+
// No known-status guard: an unknown string falls through the switch to
|
|
99
|
+
// undefined, which is neither hit source (the guard survived item 654).
|
|
101
100
|
const source = deriveCacheSource(status as TaskStatus)
|
|
102
101
|
return source === 'local' || source === 'remote'
|
|
103
102
|
}
|
|
@@ -169,6 +168,18 @@ export interface TaskTelemetry {
|
|
|
169
168
|
* / `--retry` produced more than one attempt. A retried-then-passed task is
|
|
170
169
|
* flaky by definition; this is the telemetry-side flaky signal. */
|
|
171
170
|
attempts?: number
|
|
171
|
+
/**
|
|
172
|
+
* On a `skipped` task: the id of the failed (or aborted) task at the
|
|
173
|
+
* root of what blocked it, through any chain of skips. Absent on a
|
|
174
|
+
* fail-fast skip and on every other status. Additive (schema stays 2).
|
|
175
|
+
*/
|
|
176
|
+
blockedBy?: string
|
|
177
|
+
/** On a `failed` task: vx's own `timeout` killed it (exit 143 is the deadline, not a signal). Additive. */
|
|
178
|
+
timedOut?: true
|
|
179
|
+
/** On a sandboxed task: how many declared-boundary violations the sandbox recorded (a failure on its own). Additive. */
|
|
180
|
+
sandboxViolations?: number
|
|
181
|
+
/** On a failed persistent task: why it never became ready. `exitCode` is the child's own when it exited. Additive. */
|
|
182
|
+
notReady?: 'timeout' | 'exited' | 'spawn'
|
|
172
183
|
/** bigint hrtime ns relative to run t=0, encoded as a decimal string. */
|
|
173
184
|
wallclockStartNs?: string
|
|
174
185
|
wallclockEndNs?: string
|
|
@@ -227,6 +238,12 @@ export interface RunSummaryRecord {
|
|
|
227
238
|
totalDurationMs: number
|
|
228
239
|
taskCount: number
|
|
229
240
|
failedCount: number
|
|
241
|
+
/**
|
|
242
|
+
* Tasks a shutdown signal or an embedder's abort killed (v3). They are
|
|
243
|
+
* not in `tasks`, which holds real runs only, so without this a stopped
|
|
244
|
+
* run read as a failure with nothing failed (item 851).
|
|
245
|
+
*/
|
|
246
|
+
abortedCount: number
|
|
230
247
|
hitCount: number
|
|
231
248
|
hitLocalCount: number
|
|
232
249
|
hitRemoteCount: number
|
|
@@ -248,7 +265,13 @@ export interface RunSummaryRecord {
|
|
|
248
265
|
export function assembleRunSummary(
|
|
249
266
|
run: RunContextRecord,
|
|
250
267
|
tasks: readonly TaskTelemetry[],
|
|
251
|
-
timing: {
|
|
268
|
+
timing: {
|
|
269
|
+
startedAt: number
|
|
270
|
+
endedAt: number
|
|
271
|
+
totalDurationMs: number
|
|
272
|
+
exitOk: boolean
|
|
273
|
+
abortedCount: number
|
|
274
|
+
},
|
|
252
275
|
): RunSummaryRecord {
|
|
253
276
|
let failedCount = 0
|
|
254
277
|
let hitLocalCount = 0
|
|
@@ -266,6 +289,7 @@ export function assembleRunSummary(
|
|
|
266
289
|
totalDurationMs: timing.totalDurationMs,
|
|
267
290
|
taskCount: tasks.length,
|
|
268
291
|
failedCount,
|
|
292
|
+
abortedCount: timing.abortedCount,
|
|
269
293
|
hitCount: hitLocalCount + hitRemoteCount,
|
|
270
294
|
hitLocalCount,
|
|
271
295
|
hitRemoteCount,
|
|
@@ -352,8 +376,9 @@ export function createTelemetrySource(args: {
|
|
|
352
376
|
// Never silently. The standing rule is that a never-fail path must still
|
|
353
377
|
// WARN — telemetry that vanishes without a word is indistinguishable from
|
|
354
378
|
// telemetry nobody configured.
|
|
379
|
+
// Every hook call site skips a disabled sink first, so this runs at most
|
|
380
|
+
// once per sink without a guard of its own (item 654).
|
|
355
381
|
const disable = (sink: TelemetrySink, hook: string, err: unknown): void => {
|
|
356
|
-
if (disabled.has(sink)) return
|
|
357
382
|
disabled.add(sink)
|
|
358
383
|
warn?.(
|
|
359
384
|
`[vx] telemetry sink '${sink.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -423,30 +448,13 @@ export function createTelemetrySource(args: {
|
|
|
423
448
|
case 'task:complete': {
|
|
424
449
|
const { node, outcome } = event
|
|
425
450
|
if (node.config.exec === undefined) return // group task
|
|
426
|
-
|
|
451
|
+
deliver({
|
|
427
452
|
v: TELEMETRY_SCHEMA_VERSION,
|
|
428
453
|
kind: 'task.end',
|
|
429
454
|
runId,
|
|
430
455
|
ts,
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
task: node.taskName,
|
|
434
|
-
status: outcome.status,
|
|
435
|
-
cacheSource: deriveCacheSource(outcome.status),
|
|
436
|
-
exitCode: outcome.exitCode,
|
|
437
|
-
durationMs: outcome.durationMs,
|
|
438
|
-
}
|
|
439
|
-
if (outcome.hash !== undefined) rec.hash = outcome.hash
|
|
440
|
-
if (outcome.cpuMs !== undefined) rec.cpuMs = outcome.cpuMs
|
|
441
|
-
if (outcome.peakRssBytes !== undefined) rec.peakRssBytes = outcome.peakRssBytes
|
|
442
|
-
if (outcome.where !== undefined) rec.where = outcome.where
|
|
443
|
-
if (outcome.outputs !== undefined) rec.outputs = outcome.outputs
|
|
444
|
-
if (outcome.attempts !== undefined) rec.attempts = outcome.attempts
|
|
445
|
-
if (outcome.wallclockStartNs !== undefined)
|
|
446
|
-
rec.wallclockStartNs = outcome.wallclockStartNs.toString()
|
|
447
|
-
if (outcome.wallclockEndNs !== undefined)
|
|
448
|
-
rec.wallclockEndNs = outcome.wallclockEndNs.toString()
|
|
449
|
-
deliver(rec)
|
|
456
|
+
...taskTelemetryOf(outcome),
|
|
457
|
+
})
|
|
450
458
|
return
|
|
451
459
|
}
|
|
452
460
|
case 'run:status':
|
|
@@ -476,7 +484,8 @@ export function createTelemetrySource(args: {
|
|
|
476
484
|
const ms = teardownTimeoutMs()
|
|
477
485
|
// Bounded, for the same reason plugin teardown is bounded in
|
|
478
486
|
// plugin-host.ts: run() awaits this BEFORE closeCache() and before it
|
|
479
|
-
// returns, and bin.ts
|
|
487
|
+
// returns, and bin.ts exits with run()'s code once it returns (through
|
|
488
|
+
// stdout's end callback since 2026-09-15; the code is still that). A sink whose
|
|
480
489
|
// flush never settles therefore drains the event loop with no exit code
|
|
481
490
|
// pending — Bun exits 0 and a FAILED run reports green, with the cache's
|
|
482
491
|
// accessed_at bumps and every later plugin's teardown lost with it.
|
|
@@ -502,3 +511,35 @@ export function createTelemetrySource(args: {
|
|
|
502
511
|
},
|
|
503
512
|
}
|
|
504
513
|
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The one projection of an outcome into `TaskTelemetry`, for the streaming
|
|
517
|
+
* `task.end` record and the summary's `tasks[]` alike. Two copies drifted:
|
|
518
|
+
* `task.end` dropped `blockedBy`, `timedOut`, `sandboxViolations` and
|
|
519
|
+
* `notReady`, so a streaming sink saw a timed-out or blocked task as a plain
|
|
520
|
+
* failure or skip (item 660).
|
|
521
|
+
*/
|
|
522
|
+
export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
|
|
523
|
+
const t: TaskTelemetry = {
|
|
524
|
+
taskId: o.node.id,
|
|
525
|
+
project: o.node.projectName,
|
|
526
|
+
task: o.node.taskName,
|
|
527
|
+
status: o.status,
|
|
528
|
+
cacheSource: deriveCacheSource(o.status),
|
|
529
|
+
exitCode: o.exitCode,
|
|
530
|
+
durationMs: o.durationMs,
|
|
531
|
+
}
|
|
532
|
+
if (o.hash !== undefined) t.hash = o.hash
|
|
533
|
+
if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
|
|
534
|
+
if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
|
|
535
|
+
if (o.where !== undefined) t.where = o.where
|
|
536
|
+
if (o.outputs !== undefined) t.outputs = o.outputs
|
|
537
|
+
if (o.attempts !== undefined) t.attempts = o.attempts
|
|
538
|
+
if (o.blockedBy !== undefined) t.blockedBy = o.blockedBy
|
|
539
|
+
if (o.timedOut === true) t.timedOut = true
|
|
540
|
+
if (o.sandboxViolations !== undefined) t.sandboxViolations = o.sandboxViolations
|
|
541
|
+
if (o.notReady !== undefined) t.notReady = o.notReady
|
|
542
|
+
if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
|
|
543
|
+
if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
|
|
544
|
+
return t
|
|
545
|
+
}
|