@vzn/vx 0.0.526 → 0.0.528
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/package.json +5 -5
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +44 -3
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/execute-task.ts +8 -0
- package/src/orchestrator/miss-reason.ts +55 -0
- package/src/orchestrator/run-context.ts +71 -1
- package/src/orchestrator/run.ts +11 -0
- package/src/orchestrator/telemetry.ts +29 -1
- package/src/workspace/workspace.ts +20 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.528",
|
|
4
4
|
"description": "The fastest task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.528",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.528",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.528",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.528"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/graph/index.ts
CHANGED
package/src/graph/scheduler.ts
CHANGED
|
@@ -36,6 +36,14 @@ export type TaskStatus =
|
|
|
36
36
|
// are partial.
|
|
37
37
|
| 'aborted'
|
|
38
38
|
|
|
39
|
+
/** What moved in a missed task's key since the last entry saved for it. */
|
|
40
|
+
export interface InputChanges {
|
|
41
|
+
/** How many components changed, appeared or went. 0: the same key, its entry gone. */
|
|
42
|
+
count: number
|
|
43
|
+
/** The first of them, by kind then name. */
|
|
44
|
+
first: readonly { kind: string; name: string; change: 'changed' | 'added' | 'removed' }[]
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
export interface TaskOutcome {
|
|
40
48
|
node: TaskNode
|
|
41
49
|
status: TaskStatus
|
|
@@ -69,6 +77,20 @@ export interface TaskOutcome {
|
|
|
69
77
|
* stream and the summary footer show it.
|
|
70
78
|
*/
|
|
71
79
|
admissionHeldMs?: number
|
|
80
|
+
/**
|
|
81
|
+
* How long this task waited ready (its deps done, or none to wait on)
|
|
82
|
+
* before it was dispatched: a full worker pool's wait, plus any
|
|
83
|
+
* `admissionHeldMs`. Absent when it started within the millisecond and
|
|
84
|
+
* on a task that never ran.
|
|
85
|
+
*/
|
|
86
|
+
queuedMs?: number
|
|
87
|
+
/** On a cacheable task that ran: how many files its key read. */
|
|
88
|
+
inputFiles?: number
|
|
89
|
+
/**
|
|
90
|
+
* On a cacheable task that ran: what its key changed since the last entry
|
|
91
|
+
* saved for it. Only when a telemetry sink asked, and the cache holds one.
|
|
92
|
+
*/
|
|
93
|
+
inputChanges?: InputChanges
|
|
72
94
|
/** v11 analytics: CPU time + peak RSS for this task's child process. */
|
|
73
95
|
cpuMs?: number
|
|
74
96
|
peakRssBytes?: number
|
|
@@ -439,9 +461,14 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
439
461
|
// decrements still happen (in finishOne) but never re-enqueue it.
|
|
440
462
|
// Everything else enqueues on the exec-tier the moment its deps
|
|
441
463
|
// complete.
|
|
464
|
+
// When each queued id became ready; dispatch takes it out.
|
|
465
|
+
const readyAt = new Map<string, number>()
|
|
466
|
+
const startedAt = Date.now()
|
|
442
467
|
for (const node of nodes.values()) {
|
|
443
468
|
if (restoreTier?.has(node.id)) restoreReady.push(node.id)
|
|
444
469
|
else if (node.deps.length === 0) execReady.push(node.id)
|
|
470
|
+
else continue
|
|
471
|
+
readyAt.set(node.id, startedAt)
|
|
445
472
|
}
|
|
446
473
|
|
|
447
474
|
let active = 0
|
|
@@ -603,7 +630,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
603
630
|
// don't wait on deps); one that finished early releases its own
|
|
604
631
|
// dependents now. Only an exec-tier dependent is enqueued.
|
|
605
632
|
if (heldRelease.delete(d)) release(d)
|
|
606
|
-
else if (!inRestoreTier(d))
|
|
633
|
+
else if (!inRestoreTier(d)) {
|
|
634
|
+
execReady.push(d)
|
|
635
|
+
readyAt.set(d, Date.now())
|
|
636
|
+
}
|
|
607
637
|
}
|
|
608
638
|
}
|
|
609
639
|
|
|
@@ -741,6 +771,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
741
771
|
continue
|
|
742
772
|
}
|
|
743
773
|
|
|
774
|
+
const queuedMs = Date.now() - (readyAt.get(id) ?? Date.now())
|
|
775
|
+
readyAt.delete(id)
|
|
744
776
|
const leave = admit(id)
|
|
745
777
|
// Listed as running on dispatch, so the policy's next ask in this
|
|
746
778
|
// tick sees it; the completion callbacks unlist it.
|
|
@@ -749,7 +781,13 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
749
781
|
const heldMs = since === undefined ? 0 : Math.max(1, Date.now() - since)
|
|
750
782
|
if (since !== undefined) heldSince.delete(id)
|
|
751
783
|
const withHold = (o: TaskOutcome): TaskOutcome =>
|
|
752
|
-
heldMs
|
|
784
|
+
heldMs === 0 && queuedMs === 0
|
|
785
|
+
? o
|
|
786
|
+
: {
|
|
787
|
+
...o,
|
|
788
|
+
...(heldMs > 0 ? { admissionHeldMs: heldMs } : {}),
|
|
789
|
+
...(queuedMs > 0 ? { queuedMs } : {}),
|
|
790
|
+
}
|
|
753
791
|
// Crash-isolated observer hook — a throwing onStart must not abort
|
|
754
792
|
// the dispatch loop (it would strand the tick with the slot held).
|
|
755
793
|
// A demoted task's second dispatch is the same task, already started.
|
|
@@ -799,7 +837,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
799
837
|
leave()
|
|
800
838
|
untrack()
|
|
801
839
|
demoted.add(id)
|
|
802
|
-
if (pending.get(id) === 0)
|
|
840
|
+
if (pending.get(id) === 0) {
|
|
841
|
+
execReady.push(id)
|
|
842
|
+
readyAt.set(id, Date.now())
|
|
843
|
+
}
|
|
803
844
|
tick()
|
|
804
845
|
return
|
|
805
846
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// stall task exec. See docs/design/event-stream-2026-06.md.
|
|
8
8
|
|
|
9
9
|
import { exitSignal } from '../exec/index.js'
|
|
10
|
-
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
10
|
+
import type { InputChanges, TaskNode, TaskOutcome } from '../graph/index.js'
|
|
11
11
|
import { isGroupTask } from '../graph/index.js'
|
|
12
12
|
import { maskedCommand, secretMask } from '../util/index.js'
|
|
13
13
|
import type { Logger } from './logger.js'
|
|
@@ -250,6 +250,12 @@ export interface OutcomeView {
|
|
|
250
250
|
blockedBy?: string
|
|
251
251
|
/** How long an `admit` policy held the task with a worker free (see `TaskOutcome`). */
|
|
252
252
|
admissionHeldMs?: number
|
|
253
|
+
/** How long the task waited ready for a worker (see `TaskOutcome`). */
|
|
254
|
+
queuedMs?: number
|
|
255
|
+
/** How many files a cacheable task's key read when it ran (see `TaskOutcome`). */
|
|
256
|
+
inputFiles?: number
|
|
257
|
+
/** What a missed task's key changed since its last saved entry (see `TaskOutcome`). */
|
|
258
|
+
inputChanges?: InputChanges
|
|
253
259
|
restored?: boolean
|
|
254
260
|
sandboxViolations?: number
|
|
255
261
|
sandboxViolationLines?: string[]
|
|
@@ -369,6 +375,9 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
|
|
|
369
375
|
if (outcome.cpuMs !== undefined) view.cpuMs = outcome.cpuMs
|
|
370
376
|
if (outcome.peakRssBytes !== undefined) view.peakRssBytes = outcome.peakRssBytes
|
|
371
377
|
if (outcome.admissionHeldMs !== undefined) view.admissionHeldMs = outcome.admissionHeldMs
|
|
378
|
+
if (outcome.queuedMs !== undefined) view.queuedMs = outcome.queuedMs
|
|
379
|
+
if (outcome.inputFiles !== undefined) view.inputFiles = outcome.inputFiles
|
|
380
|
+
if (outcome.inputChanges !== undefined) view.inputChanges = outcome.inputChanges
|
|
372
381
|
if (outcome.timedOut === true) view.timedOut = true
|
|
373
382
|
if (outcome.notReady !== undefined) view.notReady = outcome.notReady
|
|
374
383
|
if (outcome.blockedBy !== undefined) view.blockedBy = outcome.blockedBy
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
import { markUnsaved, saveMiss, type OutputDirSnapshot } from './miss-save.js'
|
|
69
69
|
import type { FingerprintWatch } from './fingerprint-watch.js'
|
|
70
70
|
import { restoreHit } from './hit-restore.js'
|
|
71
|
+
import type { MissExplainer } from './miss-reason.js'
|
|
71
72
|
import { shellVerdict } from './shell-verdict.js'
|
|
72
73
|
// The hit path's entry stays importable from here (tests).
|
|
73
74
|
export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
|
|
@@ -147,6 +148,8 @@ export interface ExecuteArgs {
|
|
|
147
148
|
liveChildren?: Set<ReturnType<typeof Bun.spawn>>
|
|
148
149
|
/** Sample a spawned task's process tree (`TelemetrySource.track`); absent when nobody samples. */
|
|
149
150
|
track?: (taskId: string, pid: number) => () => void
|
|
151
|
+
/** Name what a missed key changed (`createMissExplainer`); absent when nobody listens. */
|
|
152
|
+
explainMiss?: MissExplainer
|
|
150
153
|
/**
|
|
151
154
|
* Run-level retry default (`--retry <n>` / `RunOptions.retries`).
|
|
152
155
|
* Explicit `exec.retries` wins, including an explicit 0. Threaded as
|
|
@@ -819,6 +822,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
819
822
|
if (c.kind === 'env' && named.includes(c.name) && !secretNamed(c.name))
|
|
820
823
|
c.hash = MASKED + c.hash
|
|
821
824
|
const inputs: TaskInputs | undefined = described?.inputs
|
|
825
|
+
const inputChanges = described !== undefined ? args.explainMiss?.(node.id, captured) : undefined
|
|
822
826
|
// A plugin may keep the request past the run (the cache closed): it gets
|
|
823
827
|
// the upstream outputs read now. Only the local floor leaves them unread.
|
|
824
828
|
if (inputs !== undefined && !isLocalExecutor(args.executor)) void inputs.upstream
|
|
@@ -1361,6 +1365,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1361
1365
|
hash,
|
|
1362
1366
|
...(attempt > 1 ? { attempts: attempt } : {}),
|
|
1363
1367
|
...(failedAttempts.length > 0 ? { failedAttempts } : {}),
|
|
1368
|
+
...(described !== undefined
|
|
1369
|
+
? { inputFiles: captured.reduce((n, c) => (c.kind === 'file' ? n + 1 : n), 0) }
|
|
1370
|
+
: {}),
|
|
1371
|
+
...(inputChanges !== undefined ? { inputChanges } : {}),
|
|
1364
1372
|
...(unkeyed ? { unkeyed: true as const } : {}),
|
|
1365
1373
|
...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
|
|
1366
1374
|
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Why a task missed the cache, for telemetry: its key's components against
|
|
2
|
+
// the last entry this cache saved for the same task. The `vx why` join
|
|
3
|
+
// (`diffKeyComponents`), asked at run time instead of after it.
|
|
4
|
+
//
|
|
5
|
+
// Paid only when a telemetry sink exists, and only on a miss: one scan of
|
|
6
|
+
// `entries` for the newest entry per task, the first time a miss asks, then
|
|
7
|
+
// one primary-key read of that entry's `entry_inputs` per miss.
|
|
8
|
+
|
|
9
|
+
import type { Database } from 'bun:sqlite'
|
|
10
|
+
import type { InputChanges } from '../graph/index.js'
|
|
11
|
+
import { diffKeyComponents } from './metrics.js'
|
|
12
|
+
import type { TaskInputComponent } from './task-hash.js'
|
|
13
|
+
|
|
14
|
+
/** How many changed components a miss names; `count` says how many there were. */
|
|
15
|
+
const NAMED = 10
|
|
16
|
+
|
|
17
|
+
export type MissExplainer = (
|
|
18
|
+
taskId: string,
|
|
19
|
+
components: readonly TaskInputComponent[],
|
|
20
|
+
) => InputChanges | undefined
|
|
21
|
+
|
|
22
|
+
export function createMissExplainer(db: Database): MissExplainer {
|
|
23
|
+
let newest: Map<string, string> | undefined
|
|
24
|
+
return (taskId, components) => {
|
|
25
|
+
try {
|
|
26
|
+
// Each task asks before its own command runs, so its own new entry is
|
|
27
|
+
// never the one it is compared with.
|
|
28
|
+
newest ??= new Map(
|
|
29
|
+
(
|
|
30
|
+
db
|
|
31
|
+
// A bare column beside MAX() comes from the row that has it.
|
|
32
|
+
.query(
|
|
33
|
+
'SELECT project, task, hash, MAX(created_at) FROM entries GROUP BY project, task',
|
|
34
|
+
)
|
|
35
|
+
.all() as { project: string; task: string; hash: string }[]
|
|
36
|
+
).map((r) => [`${r.project}#${r.task}`, r.hash]),
|
|
37
|
+
)
|
|
38
|
+
const prev = newest.get(taskId)
|
|
39
|
+
if (prev === undefined) return undefined
|
|
40
|
+
const before = db
|
|
41
|
+
.query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
|
|
42
|
+
.all(prev) as TaskInputComponent[]
|
|
43
|
+
// Pruned, or saved before fingerprints were kept: nothing to compare.
|
|
44
|
+
if (before.length === 0) return undefined
|
|
45
|
+
const { entries } = diffKeyComponents(before, components)
|
|
46
|
+
return {
|
|
47
|
+
count: entries.length,
|
|
48
|
+
first: entries.slice(0, NAMED).map(({ kind, name, change }) => ({ kind, name, change })),
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// History is observability: an unreadable one explains nothing.
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -29,6 +29,16 @@ export interface CiContext {
|
|
|
29
29
|
/** Which CI matched: 'github' | 'gitlab' | 'buildkite' | 'circleci'
|
|
30
30
|
* | 'generic' (bare `CI`), or null when no CI env is present. */
|
|
31
31
|
provider: string | null
|
|
32
|
+
/** The provider's page for this run (a GitHub Actions run, a GitLab pipeline). */
|
|
33
|
+
runUrl?: string
|
|
34
|
+
/** The pull or merge request number this run builds. */
|
|
35
|
+
change?: string
|
|
36
|
+
/** The workflow or pipeline name. */
|
|
37
|
+
pipeline?: string
|
|
38
|
+
/** The job (or step) within it. */
|
|
39
|
+
job?: string
|
|
40
|
+
/** 1 on a first run, 2 on its first re-run. */
|
|
41
|
+
attempt?: number
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
export interface HostContext {
|
|
@@ -307,11 +317,71 @@ function isTruthy(v: string | undefined): boolean {
|
|
|
307
317
|
*/
|
|
308
318
|
export function detectCi(env: NodeJS.ProcessEnv | Record<string, string | undefined>): CiContext {
|
|
309
319
|
for (const [varName, provider] of CI_PROVIDERS) {
|
|
310
|
-
if (isTruthy(env[varName])) return { ci: true, provider }
|
|
320
|
+
if (isTruthy(env[varName])) return { ci: true, provider, ...ciLinks(provider, env) }
|
|
311
321
|
}
|
|
312
322
|
return { ci: false, provider: null }
|
|
313
323
|
}
|
|
314
324
|
|
|
325
|
+
type CiLinks = Pick<CiContext, 'runUrl' | 'change' | 'pipeline' | 'job' | 'attempt'>
|
|
326
|
+
|
|
327
|
+
/** Where a dashboard links back to: each provider's own documented variables. */
|
|
328
|
+
function ciLinks(provider: string, env: Record<string, string | undefined>): CiLinks {
|
|
329
|
+
const v = (k: string): string | undefined => (env[k] === '' ? undefined : env[k])
|
|
330
|
+
const n = (raw: string | undefined, plus = 0): number | undefined => {
|
|
331
|
+
const x = raw === undefined ? NaN : Number(raw)
|
|
332
|
+
return Number.isInteger(x) && x >= 0 ? x + plus : undefined
|
|
333
|
+
}
|
|
334
|
+
let links: Record<string, string | number | undefined>
|
|
335
|
+
switch (provider) {
|
|
336
|
+
case 'github': {
|
|
337
|
+
const server = v('GITHUB_SERVER_URL') ?? 'https://github.com'
|
|
338
|
+
const repo = v('GITHUB_REPOSITORY')
|
|
339
|
+
const id = v('GITHUB_RUN_ID')
|
|
340
|
+
links = {
|
|
341
|
+
runUrl: repo && id ? `${server}/${repo}/actions/runs/${id}` : undefined,
|
|
342
|
+
// `refs/pull/<n>/merge` on a pull_request event.
|
|
343
|
+
change: /^refs\/pull\/(\d+)\//.exec(v('GITHUB_REF') ?? '')?.[1],
|
|
344
|
+
pipeline: v('GITHUB_WORKFLOW'),
|
|
345
|
+
job: v('GITHUB_JOB'),
|
|
346
|
+
attempt: n(v('GITHUB_RUN_ATTEMPT')),
|
|
347
|
+
}
|
|
348
|
+
break
|
|
349
|
+
}
|
|
350
|
+
case 'gitlab':
|
|
351
|
+
links = {
|
|
352
|
+
runUrl: v('CI_PIPELINE_URL'),
|
|
353
|
+
change: v('CI_MERGE_REQUEST_IID'),
|
|
354
|
+
pipeline: v('CI_PIPELINE_NAME'),
|
|
355
|
+
job: v('CI_JOB_NAME'),
|
|
356
|
+
}
|
|
357
|
+
break
|
|
358
|
+
case 'buildkite': {
|
|
359
|
+
const pr = v('BUILDKITE_PULL_REQUEST')
|
|
360
|
+
links = {
|
|
361
|
+
runUrl: v('BUILDKITE_BUILD_URL'),
|
|
362
|
+
// `false` when the build is not for one.
|
|
363
|
+
change: pr !== undefined && /^\d+$/.test(pr) ? pr : undefined,
|
|
364
|
+
pipeline: v('BUILDKITE_PIPELINE_SLUG'),
|
|
365
|
+
job: v('BUILDKITE_LABEL'),
|
|
366
|
+
// Counts re-runs from 0.
|
|
367
|
+
attempt: n(v('BUILDKITE_RETRY_COUNT'), 1),
|
|
368
|
+
}
|
|
369
|
+
break
|
|
370
|
+
}
|
|
371
|
+
case 'circleci':
|
|
372
|
+
links = {
|
|
373
|
+
runUrl: v('CIRCLE_BUILD_URL'),
|
|
374
|
+
// The PR's URL, ending in its number.
|
|
375
|
+
change: /\/(\d+)$/.exec(v('CIRCLE_PULL_REQUEST') ?? '')?.[1] ?? v('CIRCLE_PR_NUMBER'),
|
|
376
|
+
job: v('CIRCLE_JOB'),
|
|
377
|
+
}
|
|
378
|
+
break
|
|
379
|
+
default:
|
|
380
|
+
return {}
|
|
381
|
+
}
|
|
382
|
+
return Object.fromEntries(Object.entries(links).filter(([, x]) => x !== undefined)) as CiLinks
|
|
383
|
+
}
|
|
384
|
+
|
|
315
385
|
/** Host name (null on failure) + platform + arch. */
|
|
316
386
|
export function captureHostContext(): HostContext {
|
|
317
387
|
let host: string | null = null
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -92,6 +92,7 @@ import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
|
|
|
92
92
|
import { createSaveLane } from './save-lane.js'
|
|
93
93
|
import { formatRunSummary } from './summary.js'
|
|
94
94
|
import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
|
|
95
|
+
import { createMissExplainer } from './miss-reason.js'
|
|
95
96
|
import type { RunOptions, RunSummary } from './options.js'
|
|
96
97
|
|
|
97
98
|
// Per run, never shared: a `vx watch` process runs many, and a shared map
|
|
@@ -747,6 +748,11 @@ async function runOnBus(
|
|
|
747
748
|
dirty: gitContext.dirty,
|
|
748
749
|
ci: ciContext.ci,
|
|
749
750
|
ciProvider: ciContext.provider,
|
|
751
|
+
...(ciContext.runUrl !== undefined ? { ciRunUrl: ciContext.runUrl } : {}),
|
|
752
|
+
...(ciContext.change !== undefined ? { ciChange: ciContext.change } : {}),
|
|
753
|
+
...(ciContext.pipeline !== undefined ? { ciPipeline: ciContext.pipeline } : {}),
|
|
754
|
+
...(ciContext.job !== undefined ? { ciJob: ciContext.job } : {}),
|
|
755
|
+
...(ciContext.attempt !== undefined ? { ciAttempt: ciContext.attempt } : {}),
|
|
750
756
|
host: hostContext.host,
|
|
751
757
|
os: hostContext.os,
|
|
752
758
|
arch: hostContext.arch,
|
|
@@ -916,6 +922,10 @@ async function runOnBus(
|
|
|
916
922
|
const dependedOn = new Set<string>()
|
|
917
923
|
for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
|
|
918
924
|
|
|
925
|
+
// Why a task missed, for a sink: a read of the history per miss, so only
|
|
926
|
+
// when a sink exists.
|
|
927
|
+
const explainMiss =
|
|
928
|
+
telemetry === undefined ? undefined : createMissExplainer(prepared.localCache.dbHandle())
|
|
919
929
|
const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
|
|
920
930
|
const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
|
|
921
931
|
const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
|
|
@@ -947,6 +957,7 @@ async function runOnBus(
|
|
|
947
957
|
persistentRegistry,
|
|
948
958
|
liveChildren,
|
|
949
959
|
...(telemetry?.track !== undefined ? { track: telemetry.track } : {}),
|
|
960
|
+
...(explainMiss !== undefined ? { explainMiss } : {}),
|
|
950
961
|
gitFilesCache,
|
|
951
962
|
hashCache,
|
|
952
963
|
...(probe !== undefined ? { preProbed: probe } : {}),
|
|
@@ -17,7 +17,13 @@
|
|
|
17
17
|
// core calls INTO; those are the behavior capabilities, kept separate.
|
|
18
18
|
|
|
19
19
|
import { sampleTrees } from '../exec/index.js'
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
isGroupTask,
|
|
22
|
+
type InputChanges,
|
|
23
|
+
type TaskNode,
|
|
24
|
+
type TaskOutcome,
|
|
25
|
+
type TaskStatus,
|
|
26
|
+
} from '../graph/index.js'
|
|
21
27
|
import { maskedCommand, settleWithin, teardownTimeoutMs } from '../util/index.js'
|
|
22
28
|
import type { RunEvent, RunEventSubscriber } from './events.js'
|
|
23
29
|
|
|
@@ -143,6 +149,16 @@ export interface RunContextRecord {
|
|
|
143
149
|
dirty: boolean | null
|
|
144
150
|
ci: boolean
|
|
145
151
|
ciProvider: string | null
|
|
152
|
+
/** The CI provider's page for this run. */
|
|
153
|
+
ciRunUrl?: string
|
|
154
|
+
/** The pull or merge request number the run builds. */
|
|
155
|
+
ciChange?: string
|
|
156
|
+
/** The CI workflow or pipeline name. */
|
|
157
|
+
ciPipeline?: string
|
|
158
|
+
/** The CI job (or step) within it. */
|
|
159
|
+
ciJob?: string
|
|
160
|
+
/** 1 on a first run, 2 on its first re-run. */
|
|
161
|
+
ciAttempt?: number
|
|
146
162
|
host: string | null
|
|
147
163
|
os: string
|
|
148
164
|
arch: string
|
|
@@ -207,6 +223,15 @@ export interface TaskTelemetry {
|
|
|
207
223
|
storedPeakRssBytes?: number
|
|
208
224
|
/** How long an `admit` policy held the task once it was ready. Additive. */
|
|
209
225
|
admissionHeldMs?: number
|
|
226
|
+
/** How long the task waited ready for a worker, any admission hold included. Additive. */
|
|
227
|
+
queuedMs?: number
|
|
228
|
+
/** On a cacheable task that ran: how many files its key read. Additive. */
|
|
229
|
+
inputFiles?: number
|
|
230
|
+
/**
|
|
231
|
+
* On a cacheable task that ran, when the cache holds an earlier entry for
|
|
232
|
+
* it: what its key changed since, the first ten named. Additive.
|
|
233
|
+
*/
|
|
234
|
+
inputChanges?: InputChanges
|
|
210
235
|
/**
|
|
211
236
|
* On a cache hit: whether outputs were written this run (`true`) or the
|
|
212
237
|
* disk already matched the entry and nothing was restored (`false`, an
|
|
@@ -741,6 +766,9 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
|
|
|
741
766
|
if (o.storedCpuMs !== undefined) t.storedCpuMs = o.storedCpuMs
|
|
742
767
|
if (o.storedPeakRssBytes !== undefined) t.storedPeakRssBytes = o.storedPeakRssBytes
|
|
743
768
|
if (o.admissionHeldMs !== undefined) t.admissionHeldMs = o.admissionHeldMs
|
|
769
|
+
if (o.queuedMs !== undefined) t.queuedMs = o.queuedMs
|
|
770
|
+
if (o.inputFiles !== undefined) t.inputFiles = o.inputFiles
|
|
771
|
+
if (o.inputChanges !== undefined) t.inputChanges = o.inputChanges
|
|
744
772
|
if (isCacheHit(o.status)) t.restored = o.restored === true
|
|
745
773
|
if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
|
|
746
774
|
if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
|
|
@@ -74,7 +74,9 @@ const decoder = new TextDecoder()
|
|
|
74
74
|
* `^task` edges vanish, upstream hashes drop out of the cache key (stale
|
|
75
75
|
* hits), and a second cache dir appears under the member. Claiming is decided
|
|
76
76
|
* with the same globs `loadWorkspace` applies, so "the root that claims me"
|
|
77
|
-
* and "the root that lists me as a project" cannot diverge.
|
|
77
|
+
* and "the root that lists me as a project" cannot diverge. An outer root
|
|
78
|
+
* that lists both the claimer and the claimed member outranks the claimer:
|
|
79
|
+
* from the claimer's own directory the walk reaches the outer root too.
|
|
78
80
|
*
|
|
79
81
|
* When no candidate claims `start` — a standalone package, or a subdirectory
|
|
80
82
|
* of a single-project repo — the nearest candidate wins (the root itself IS
|
|
@@ -92,6 +94,9 @@ export async function findWorkspaceRoot(
|
|
|
92
94
|
// The nearest manifest with workspace globs of its own, once passed: an
|
|
93
95
|
// outer root claims the tree only by listing this directory itself.
|
|
94
96
|
let inner: string | null = null
|
|
97
|
+
// The root that claims `start`, and the members it claimed it through.
|
|
98
|
+
let claimed: string | null = null
|
|
99
|
+
let members: string[] = []
|
|
95
100
|
while (true) {
|
|
96
101
|
let globs: string[] | null
|
|
97
102
|
try {
|
|
@@ -105,12 +110,23 @@ export async function findWorkspaceRoot(
|
|
|
105
110
|
}
|
|
106
111
|
if (globs !== null) {
|
|
107
112
|
nearest ??= dir
|
|
108
|
-
if (
|
|
113
|
+
if (claimed !== null) {
|
|
114
|
+
// An outer root that lists both the claimer and the member owns
|
|
115
|
+
// them: `packages/**` holding `packages/inner` (with `workspaces:
|
|
116
|
+
// ['sub']`) and `packages/inner/sub` resolved `sub` to `inner` and
|
|
117
|
+
// `inner` to the outer root, two roots and two keys for one tree.
|
|
118
|
+
if (claimsMember(dir, [claimed], globs) && claimsMember(dir, members, globs)) claimed = dir
|
|
119
|
+
} else if (claimsMember(dir, inner === null ? below : [inner], globs)) {
|
|
120
|
+
claimed = dir
|
|
121
|
+
members = inner === null ? [...below] : [inner]
|
|
122
|
+
}
|
|
109
123
|
// pnpm takes the nearest `pnpm-workspace.yaml` as the root, listed or
|
|
110
124
|
// not. Walking past it, `apps/inner` resolved to the outer workspace
|
|
111
125
|
// while `apps/inner/pkgs/x` resolved to the inner one: two roots and
|
|
112
126
|
// two caches for one tree (item 990). Already read: `reads` holds it.
|
|
113
|
-
if ((await readOnce(reads, path.join(dir, 'pnpm-workspace.yaml'))) !== null)
|
|
127
|
+
if ((await readOnce(reads, path.join(dir, 'pnpm-workspace.yaml'))) !== null) {
|
|
128
|
+
return claimed ?? dir
|
|
129
|
+
}
|
|
114
130
|
}
|
|
115
131
|
const parent = path.dirname(dir)
|
|
116
132
|
if (parent === dir) break
|
|
@@ -126,6 +142,7 @@ export async function findWorkspaceRoot(
|
|
|
126
142
|
if (inner === null && globs !== null && globs.length > 0) inner = dir
|
|
127
143
|
dir = parent
|
|
128
144
|
}
|
|
145
|
+
if (claimed !== null) return claimed
|
|
129
146
|
if (nearest !== null) return nearest
|
|
130
147
|
throw new UserError(
|
|
131
148
|
`Could not find a workspace root in any parent of ${start} ` +
|