@vzn/vx 0.0.18 → 0.0.21
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/LICENSE +1 -1
- package/README.md +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// aren't real work and would inflate "N total" misleadingly.
|
|
5
5
|
|
|
6
6
|
import type { TaskOutcome } from '../graph/index.js'
|
|
7
|
+
import type { FlakyFinding } from './failure-mode.js'
|
|
7
8
|
import { paint, type ColorSupport } from './colors.js'
|
|
8
9
|
import { tallyOutcomes } from './tally.js'
|
|
9
10
|
import { isGroupTask } from '../graph/index.js'
|
|
@@ -101,13 +102,17 @@ export interface SummaryStats {
|
|
|
101
102
|
upToDate: number
|
|
102
103
|
restoredLocal: number
|
|
103
104
|
restoredRemote: number
|
|
104
|
-
/** Tasks that executed (success + failed) — the
|
|
105
|
+
/** Tasks that executed (success + failed) with a `cache` block — the misses. */
|
|
105
106
|
miss: number
|
|
107
|
+
/** Executed with no `cache` block: never consulted the cache, not a miss. */
|
|
108
|
+
noCache?: number
|
|
106
109
|
/** Still to run (live section only; 0 in the final summary) — renders
|
|
107
110
|
* as a gray ▱ remainder so the live meters FILL toward the final. */
|
|
108
111
|
left?: number
|
|
109
112
|
/** Cache-miss duration spread (only tasks that actually executed). */
|
|
110
113
|
spread: { maxMs: number; minMs: number; sumMs: number; count: number } | null
|
|
114
|
+
/** Tasks an `admit` policy held with a worker free, and the waits summed. Absent with no hold. */
|
|
115
|
+
held?: { count: number; sumMs: number }
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
/**
|
|
@@ -125,14 +130,6 @@ export interface RunContext {
|
|
|
125
130
|
remoteCacheEnabled: boolean
|
|
126
131
|
/** Total projects discovery found — the bar's denominator. */
|
|
127
132
|
workspaceProjectCount?: number
|
|
128
|
-
/**
|
|
129
|
-
* Resource-admission budgets (`exec.resources`), set ONLY when at
|
|
130
|
-
* least one task declared a reservation — a plain run's footer is
|
|
131
|
-
* byte-identical. Shown on the `info` row so a packed run says what
|
|
132
|
-
* it packed against. memBudget in bytes.
|
|
133
|
-
*/
|
|
134
|
-
cpuBudget?: number
|
|
135
|
-
memBudget?: number
|
|
136
133
|
}
|
|
137
134
|
|
|
138
135
|
export function formatSummarySection(
|
|
@@ -143,6 +140,7 @@ export function formatSummarySection(
|
|
|
143
140
|
): string[] {
|
|
144
141
|
const hits = stats.upToDate + stats.restoredLocal + stats.restoredRemote
|
|
145
142
|
const miss = stats.miss
|
|
143
|
+
const noCache = stats.noCache ?? 0
|
|
146
144
|
const left = stats.left ?? 0
|
|
147
145
|
const remainder = { n: left, color: '', glyph: '\u25b1', dim: true }
|
|
148
146
|
|
|
@@ -206,10 +204,11 @@ export function formatSummarySection(
|
|
|
206
204
|
|
|
207
205
|
// Cache meter + numbers, fixed order: miss · up-to-date · local ·
|
|
208
206
|
// remote.
|
|
209
|
-
if (miss + hits + left + stats.skipped > 0) {
|
|
207
|
+
if (miss + noCache + hits + left + stats.skipped > 0) {
|
|
210
208
|
const cacheBar = segmentBar(
|
|
211
209
|
[
|
|
212
210
|
{ n: miss, color: ERROR },
|
|
211
|
+
{ n: noCache, color: '', dim: true },
|
|
213
212
|
{ n: stats.upToDate, color: SUCCESS },
|
|
214
213
|
{ n: stats.restoredLocal, color: LOCAL },
|
|
215
214
|
{ n: stats.restoredRemote, color: REMOTE },
|
|
@@ -220,6 +219,7 @@ export function formatSummarySection(
|
|
|
220
219
|
)
|
|
221
220
|
const cacheParts: string[] = []
|
|
222
221
|
if (miss > 0) cacheParts.push(paint(ERROR, `${miss} miss`, colors))
|
|
222
|
+
if (noCache > 0) cacheParts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
|
|
223
223
|
if (stats.upToDate > 0) cacheParts.push(paint(SUCCESS, `${stats.upToDate} up-to-date`, colors))
|
|
224
224
|
if (stats.restoredLocal > 0)
|
|
225
225
|
cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
|
|
@@ -248,9 +248,14 @@ export function formatSummarySection(
|
|
|
248
248
|
if (context.concurrency !== undefined)
|
|
249
249
|
info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
|
|
250
250
|
info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
251
|
+
// The plugin's hand on the run: a task a policy held with a worker
|
|
252
|
+
// free would otherwise have started; without the line nothing says
|
|
253
|
+
// the policy acted.
|
|
254
|
+
if (stats.held !== undefined && stats.held.count > 0) {
|
|
255
|
+
info.push(
|
|
256
|
+
`admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'} ${formatDuration(stats.held.sumMs)}`,
|
|
257
|
+
)
|
|
258
|
+
}
|
|
254
259
|
lines.push('', row('info', join(info)), row('time', `${formatDuration(totalMs)}${spread}`))
|
|
255
260
|
} else {
|
|
256
261
|
lines.push('', row('time', `${formatDuration(totalMs)}${spread}`))
|
|
@@ -260,29 +265,28 @@ export function formatSummarySection(
|
|
|
260
265
|
|
|
261
266
|
// Local byte formatter — the orchestrator can't import cli/format.ts
|
|
262
267
|
// (module boundary), and the footer only needs one compact form.
|
|
263
|
-
function formatBudgetBytes(bytes: number): string {
|
|
264
|
-
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
265
|
-
let v = bytes
|
|
266
|
-
let u = 0
|
|
267
|
-
while (v >= 1024 && u < units.length - 1) {
|
|
268
|
-
v /= 1024
|
|
269
|
-
u++
|
|
270
|
-
}
|
|
271
|
-
return `${u === 0 ? v : v.toFixed(1)} ${units[u]}`
|
|
272
|
-
}
|
|
273
|
-
|
|
274
268
|
export function formatRunSummary(
|
|
275
269
|
outcomes: readonly TaskOutcome[],
|
|
276
270
|
totalMs: number,
|
|
277
271
|
colors: ColorSupport = NO_COLOR,
|
|
278
272
|
context?: RunContext,
|
|
279
273
|
): string[] {
|
|
274
|
+
// Counted here, not in the Tally: that object is the `--summarize` payload
|
|
275
|
+
// (ten documented fields) and a task with no `cache` block is a terminal
|
|
276
|
+
// distinction — it never consulted the cache, so it is not a miss.
|
|
277
|
+
const noCache = outcomes.filter(
|
|
278
|
+
(o) =>
|
|
279
|
+
!isGroupTask(o.node) &&
|
|
280
|
+
(o.status === 'success' || o.status === 'failed') &&
|
|
281
|
+
o.node.config.cache === undefined,
|
|
282
|
+
).length
|
|
280
283
|
const t = tallyOutcomes(outcomes)
|
|
281
284
|
// Spread over executed tasks only — `success`/`failed` statuses ran
|
|
282
285
|
// a command; cache-hit statuses replayed one.
|
|
283
286
|
const durations = outcomes
|
|
284
287
|
.filter((o) => (o.status === 'success' || o.status === 'failed') && !isGroupTask(o.node))
|
|
285
288
|
.map((o) => o.durationMs)
|
|
289
|
+
const heldOutcomes = outcomes.filter((o) => o.admissionHeldMs !== undefined)
|
|
286
290
|
return formatSummarySection(
|
|
287
291
|
{
|
|
288
292
|
failed: t.failed,
|
|
@@ -292,7 +296,8 @@ export function formatRunSummary(
|
|
|
292
296
|
upToDate: t.upToDate,
|
|
293
297
|
restoredLocal: t.restoredLocal,
|
|
294
298
|
restoredRemote: t.restoredRemote,
|
|
295
|
-
miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote),
|
|
299
|
+
miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote) - noCache,
|
|
300
|
+
noCache,
|
|
296
301
|
spread:
|
|
297
302
|
durations.length > 0
|
|
298
303
|
? {
|
|
@@ -302,6 +307,14 @@ export function formatRunSummary(
|
|
|
302
307
|
count: durations.length,
|
|
303
308
|
}
|
|
304
309
|
: null,
|
|
310
|
+
...(heldOutcomes.length > 0
|
|
311
|
+
? {
|
|
312
|
+
held: {
|
|
313
|
+
count: heldOutcomes.length,
|
|
314
|
+
sumMs: heldOutcomes.reduce((sum, o) => sum + o.admissionHeldMs!, 0),
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
: {}),
|
|
305
318
|
},
|
|
306
319
|
totalMs,
|
|
307
320
|
colors,
|
|
@@ -330,6 +343,36 @@ export function formatAbortedSection(outcomes: readonly TaskOutcome[]): string[]
|
|
|
330
343
|
return lines
|
|
331
344
|
}
|
|
332
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Post-summary section naming the tasks this run proved flaky: identical
|
|
348
|
+
* inputs, both outcomes on record (or a retry within the run). A red run
|
|
349
|
+
* whose failure has passed on these exact inputs before is not a break to
|
|
350
|
+
* bisect, and a green run that only passed on the second attempt is not
|
|
351
|
+
* green — either way the footer's counts alone would say nothing. Empty
|
|
352
|
+
* when nothing was.
|
|
353
|
+
*/
|
|
354
|
+
export function formatFlakySection(findings: readonly FlakyFinding[]): string[] {
|
|
355
|
+
if (findings.length === 0) return []
|
|
356
|
+
const lines = [
|
|
357
|
+
'',
|
|
358
|
+
` Flaky: ${findings.length} task${findings.length === 1 ? '' : 's'} with the same inputs both passing and failing on record`,
|
|
359
|
+
]
|
|
360
|
+
for (const f of findings) {
|
|
361
|
+
const times = (n: number): string => `${n}\u00d7`
|
|
362
|
+
const history =
|
|
363
|
+
f.status === 'failed'
|
|
364
|
+
? `failed on inputs that passed ${times(f.passes)} before`
|
|
365
|
+
: f.failures > 0
|
|
366
|
+
? `passed on inputs that failed ${times(f.failures)} before`
|
|
367
|
+
: 'passed'
|
|
368
|
+
const retried = f.attempts > 1 ? ` \u00b7 ${f.attempts} attempts this run` : ''
|
|
369
|
+
lines.push(
|
|
370
|
+
` ${f.status === 'failed' ? '\u2717' : '\u2713'} ${f.taskId} \u2014 ${history}${retried}`,
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
return lines
|
|
374
|
+
}
|
|
375
|
+
|
|
333
376
|
export function formatDuration(ms: number): string {
|
|
334
377
|
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
335
378
|
return `${(ms / 1000).toFixed(2)}s`
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import type { TaskConfig, CacheConfig } from '../config.js'
|
|
3
|
+
import type { WorkspaceFilesCache } from '../cache/index.js'
|
|
3
4
|
import {
|
|
4
5
|
type CacheKeyInput,
|
|
5
6
|
type CacheLayer,
|
|
@@ -32,6 +33,7 @@ export interface HashCache {
|
|
|
32
33
|
taskConfig: WeakMap<TaskConfig, string>
|
|
33
34
|
runtime: Map<string, Promise<string>>
|
|
34
35
|
workspaceRuntime: Map<string, Promise<string>>
|
|
36
|
+
workspaceFiles: WorkspaceFilesCache
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export function createHashCache(): HashCache {
|
|
@@ -40,6 +42,7 @@ export function createHashCache(): HashCache {
|
|
|
40
42
|
taskConfig: new WeakMap(),
|
|
41
43
|
runtime: new Map(),
|
|
42
44
|
workspaceRuntime: new Map(),
|
|
45
|
+
workspaceFiles: new Map(),
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
48
|
|
|
@@ -174,6 +177,7 @@ async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
|
|
|
174
177
|
? {
|
|
175
178
|
runtimeCache: args.hashCache.runtime,
|
|
176
179
|
workspaceRuntimeCache: args.hashCache.workspaceRuntime,
|
|
180
|
+
workspaceFilesCache: args.hashCache.workspaceFiles,
|
|
177
181
|
}
|
|
178
182
|
: {}),
|
|
179
183
|
})
|
|
@@ -264,25 +268,23 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
|
|
|
264
268
|
}
|
|
265
269
|
|
|
266
270
|
/**
|
|
267
|
-
* Project the config for hashing: `exec.
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* — their keys are distinct by design (see the decision log); stripping
|
|
281
|
-
* them retroactively would bump CACHE_VERSION.
|
|
271
|
+
* Project the config for hashing: `exec.remote` is a pure PLACEMENT hint
|
|
272
|
+
* with zero effect on outputs, so it is stripped — pinning a task to this
|
|
273
|
+
* machine never busts a cache. It must not: the whole contract of a remote
|
|
274
|
+
* executor is that the same command over the same inputs produces the
|
|
275
|
+
* same outputs, so a key that moved when placement changed would split a
|
|
276
|
+
* laptop from a worker pool over nothing. (`exec.resources` rode the same
|
|
277
|
+
* rule until 2026-09-12, when reservations left the config for the
|
|
278
|
+
* schedule plugin that learns them; a config never declares them now.) A
|
|
279
|
+
* config that declares no `remote` takes the fast path and stringifies
|
|
280
|
+
* byte-identically to before the field existed (why this needs no
|
|
281
|
+
* CACHE_VERSION bump). `timeout`/`retries` stay folded — their keys are
|
|
282
|
+
* distinct by design (see the decision log); stripping them retroactively
|
|
283
|
+
* would bump CACHE_VERSION.
|
|
282
284
|
*/
|
|
283
285
|
function hashableConfig(cfg: TaskConfig): unknown {
|
|
284
|
-
if (cfg.exec?.
|
|
285
|
-
const {
|
|
286
|
+
if (cfg.exec?.remote === undefined) return cfg
|
|
287
|
+
const { remote: _remote, ...execRest } = cfg.exec
|
|
286
288
|
return { ...cfg, exec: execRest }
|
|
287
289
|
}
|
|
288
290
|
|
|
@@ -343,5 +345,5 @@ async function doHashProjectPackageJson(
|
|
|
343
345
|
if (!(await Bun.file(filePath).exists())) return ''
|
|
344
346
|
// Route through the cache layer's mtime+size fast path so the
|
|
345
347
|
// typical re-run sees a stat + SQLite lookup instead of a file read.
|
|
346
|
-
return
|
|
348
|
+
return cache.hashFile(filePath)
|
|
347
349
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// The shared per-task log capture primitive (task-logs-2026-07 §1). ONE
|
|
2
|
-
// bounded-tail buffer every telemetry sink uses —
|
|
3
|
-
//
|
|
4
|
-
// OTLP logs exporter — so the capping rules can't drift between capture sites.
|
|
2
|
+
// bounded-tail buffer every telemetry sink uses — today `@vzn/vx-otel`'s
|
|
3
|
+
// sink — so the capping rules can't drift between capture sites.
|
|
5
4
|
//
|
|
6
5
|
// It lives in CORE, beside the telemetry contract, for the same reason
|
|
7
6
|
// `assembleRunSummary` does: two sinks that each roll their own bounded buffer
|
|
@@ -25,8 +24,7 @@ import type { TaskStatus } from '../graph/index.js'
|
|
|
25
24
|
import type { CacheSource } from './telemetry.js'
|
|
26
25
|
|
|
27
26
|
/** Version of the drained-bundle shape below. It is the canonical drained
|
|
28
|
-
* logs format, not one transport's:
|
|
29
|
-
* it verbatim, and any other sink ships the same object. */
|
|
27
|
+
* logs format, not one transport's: every sink ships the same object. */
|
|
30
28
|
export const LOG_WIRE_VERSION = 1
|
|
31
29
|
|
|
32
30
|
/**
|
|
@@ -256,15 +254,14 @@ export class TaskLogBuffer {
|
|
|
256
254
|
* enough failing tasks to blow the budget). A stub costs a few dozen bytes,
|
|
257
255
|
* keeps the `content.length === charsFull - truncatedHeadChars` accounting
|
|
258
256
|
* honest, and renders through the truncation banner the per-task cap
|
|
259
|
-
* already uses
|
|
260
|
-
*
|
|
261
|
-
* `truncated_head`) — the two sides now degrade the same way.
|
|
257
|
+
* already uses — a store that enforces its own run budget should degrade
|
|
258
|
+
* the same way (slice to empty, add the remainder to the truncated count).
|
|
262
259
|
*
|
|
263
260
|
* The cost is a stub per evicted task in the end-of-run bundle: measured at
|
|
264
261
|
* ~190 JSON bytes each, so ~1.9 MiB at the 10k-task scale target and ~9.3 MiB
|
|
265
|
-
* at 50k
|
|
266
|
-
*
|
|
267
|
-
*
|
|
262
|
+
* at 50k — a sink that posts the whole bundle in one request sizes its cap
|
|
263
|
+
* against that; one that ships per task as it finishes (`takeEntry`) never
|
|
264
|
+
* sees it.
|
|
268
265
|
*/
|
|
269
266
|
private evictToBudget(): void {
|
|
270
267
|
if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) return
|
|
@@ -40,7 +40,17 @@ function checkSink(sink: unknown): TelemetrySink {
|
|
|
40
40
|
if (wants !== undefined && !Array.isArray(wants)) {
|
|
41
41
|
throw new Error(`telemetry sink 'wants' must be an array, got ${typeof wants}`)
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
// Every handler is optional, so `{ nope: true }` was a valid sink that
|
|
44
|
+
// subscribed to the bus and heard nothing — the plugin was "on" and did
|
|
45
|
+
// nothing. Refused here, which the caller turns into the same warning
|
|
46
|
+
// a throwing hook gets: never a failed run.
|
|
47
|
+
const s = sink as TelemetrySink
|
|
48
|
+
if (typeof s.onRecord !== 'function' && typeof s.onRunSummary !== 'function') {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'telemetry sink handles nothing: neither onRecord nor onRunSummary is a function',
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
return s
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
/**
|
|
@@ -16,7 +16,7 @@
|
|
|
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 {
|
|
19
|
+
import type { 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
|
|
|
@@ -160,7 +160,7 @@ export interface TaskTelemetry {
|
|
|
160
160
|
cpuMs?: number
|
|
161
161
|
peakRssBytes?: number
|
|
162
162
|
/** Executor-reported placement (a worker id) — absent for local runs.
|
|
163
|
-
* Additive-optional, no schema bump (the `attempts
|
|
163
|
+
* Additive-optional, no schema bump (the `attempts` precedent). */
|
|
164
164
|
where?: string
|
|
165
165
|
/** `'deferred'` when the outputs stayed remote (`--download=none`).
|
|
166
166
|
* Additive-optional, same no-bump precedent as `where`. */
|
|
@@ -169,18 +169,6 @@ export interface TaskTelemetry {
|
|
|
169
169
|
* / `--retry` produced more than one attempt. A retried-then-passed task is
|
|
170
170
|
* flaky by definition; this is the telemetry-side flaky signal. */
|
|
171
171
|
attempts?: number
|
|
172
|
-
/** Cache-correctness verdict — set ONLY on a `--verify` run (absent
|
|
173
|
-
* otherwise). A `nondeterministic` verdict means the task's cache entry is
|
|
174
|
-
* unsound (its outputs aren't a pure function of its declared inputs); this
|
|
175
|
-
* is the telemetry-side hermeticity signal a dashboard surfaces. */
|
|
176
|
-
verify?: VerifyVerdict
|
|
177
|
-
/** Output-tree fingerprint — set ONLY under a fingerprinting `--verify*`
|
|
178
|
-
* mode (`--verify` / `=all` / `=fingerprint`), executed tasks only. A
|
|
179
|
-
* connected serve pairs fingerprints for the SAME `hash` across platforms
|
|
180
|
-
* and names diverging outputs (the cross-machine diff). Absent on plain
|
|
181
|
-
* runs — additive-optional, no schema bump (the `attempts`/`verify`
|
|
182
|
-
* precedent). */
|
|
183
|
-
outputFp?: OutputFingerprint
|
|
184
172
|
/** bigint hrtime ns relative to run t=0, encoded as a decimal string. */
|
|
185
173
|
wallclockStartNs?: string
|
|
186
174
|
wallclockEndNs?: string
|
|
@@ -254,7 +242,7 @@ export interface RunSummaryRecord {
|
|
|
254
242
|
* distributed run and a local run produce byte-identical summaries and land in
|
|
255
243
|
* the same ingest. The per-task tallies (taskCount / failedCount /
|
|
256
244
|
* hitLocal|Remote|Count) derive from `tasks`; `totalDurationMs` (wall time) and
|
|
257
|
-
* `exitOk` (the run's overall verdict — which counts skipped
|
|
245
|
+
* `exitOk` (the run's overall verdict — which counts skipped tasks beyond the
|
|
258
246
|
* recorded task list) are run-level facts and are passed in.
|
|
259
247
|
*/
|
|
260
248
|
export function assembleRunSummary(
|
|
@@ -454,8 +442,6 @@ export function createTelemetrySource(args: {
|
|
|
454
442
|
if (outcome.where !== undefined) rec.where = outcome.where
|
|
455
443
|
if (outcome.outputs !== undefined) rec.outputs = outcome.outputs
|
|
456
444
|
if (outcome.attempts !== undefined) rec.attempts = outcome.attempts
|
|
457
|
-
if (outcome.verify !== undefined) rec.verify = outcome.verify
|
|
458
|
-
if (outcome.outputFp !== undefined) rec.outputFp = outcome.outputFp
|
|
459
445
|
if (outcome.wallclockStartNs !== undefined)
|
|
460
446
|
rec.wallclockStartNs = outcome.wallclockStartNs.toString()
|
|
461
447
|
if (outcome.wallclockEndNs !== undefined)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// What this process may actually use, as its cgroup bounds it. Inside a
|
|
2
|
+
// container `navigator.hardwareConcurrency` and `os.totalmem()` report
|
|
3
|
+
// the HOST's cores and RAM; the cgroup that runs the job (a docker
|
|
4
|
+
// executor's `--cpus` / `--memory`, a Kubernetes limit) is what the
|
|
5
|
+
// kernel enforces. A limit set on an ANCESTOR binds too, so each reader
|
|
6
|
+
// walks from this process's cgroup up to the root and keeps the
|
|
7
|
+
// tightest value. Linux only; elsewhere the machine's numbers stand.
|
|
8
|
+
// Both hierarchies: v2 (unified, `memory.max` / `cpu.max`) and v1 (one
|
|
9
|
+
// mount per controller, `memory.limit_in_bytes` / `cpu.cfs_*_us`).
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
export interface CgroupProbe {
|
|
16
|
+
/** The cgroup filesystem root. Default `/sys/fs/cgroup`. */
|
|
17
|
+
readonly root?: string
|
|
18
|
+
/** This process's cgroup membership file. Default `/proc/self/cgroup`. */
|
|
19
|
+
readonly procSelfCgroup?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The tightest memory limit on this process's cgroup or any ancestor, in
|
|
24
|
+
* bytes; undefined when no cgroup bounds it. v2: `memory.max`, `max`
|
|
25
|
+
* binds nothing. v1: `memory.limit_in_bytes`, where an unlimited level
|
|
26
|
+
* writes the page-counter maximum (~2^63) — anything past 2^60 binds
|
|
27
|
+
* nothing.
|
|
28
|
+
*/
|
|
29
|
+
export function cgroupMemoryLimitBytes(probe: CgroupProbe = {}): number | undefined {
|
|
30
|
+
return tightest(probe, 'memory', {
|
|
31
|
+
v2: (dir) =>
|
|
32
|
+
parseLimit(read(dir, 'memory.max'), (raw) => (raw === 'max' ? undefined : Number(raw))),
|
|
33
|
+
v1: (dir) =>
|
|
34
|
+
parseLimit(read(dir, 'memory.limit_in_bytes'), (raw) => {
|
|
35
|
+
const n = Number(raw)
|
|
36
|
+
return n >= 2 ** 60 ? undefined : n
|
|
37
|
+
}),
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The tightest CPU quota on this process's cgroup or any ancestor, in
|
|
43
|
+
* cores (a fraction is possible: `--cpus=1.5`); undefined when no cgroup
|
|
44
|
+
* bounds it. v2: `cpu.max` is `<quota> <period>` or `max <period>`. v1:
|
|
45
|
+
* `cpu.cfs_quota_us` (-1 for none) over `cpu.cfs_period_us`.
|
|
46
|
+
*/
|
|
47
|
+
export function cgroupCpuQuota(probe: CgroupProbe = {}): number | undefined {
|
|
48
|
+
return tightest(probe, 'cpu', {
|
|
49
|
+
v2: (dir) => {
|
|
50
|
+
const raw = read(dir, 'cpu.max')
|
|
51
|
+
if (raw === undefined) return undefined
|
|
52
|
+
const [quota, period] = raw.split(/\s+/)
|
|
53
|
+
return quota === 'max' ? undefined : ratio(Number(quota), Number(period ?? '100000'))
|
|
54
|
+
},
|
|
55
|
+
v1: (dir) => {
|
|
56
|
+
const quota = read(dir, 'cpu.cfs_quota_us')
|
|
57
|
+
const period = read(dir, 'cpu.cfs_period_us')
|
|
58
|
+
if (quota === undefined || period === undefined) return undefined
|
|
59
|
+
const q = Number(quota)
|
|
60
|
+
return q <= 0 ? undefined : ratio(q, Number(period))
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The machine's total memory, capped by its cgroup limit. */
|
|
66
|
+
export function machineMemoryBytes(probe: CgroupProbe = {}): number {
|
|
67
|
+
const total = os.totalmem()
|
|
68
|
+
if (process.platform !== 'linux') return total
|
|
69
|
+
const limit = cgroupMemoryLimitBytes(probe)
|
|
70
|
+
return limit === undefined ? total : Math.min(total, limit)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The cores this process may run on, capped by its cgroup CPU quota,
|
|
75
|
+
* rounded up (a 1.5-core quota is two workers, not one) and never below
|
|
76
|
+
* one. The default worker count.
|
|
77
|
+
*/
|
|
78
|
+
export function machineParallelism(probe: CgroupProbe = {}): number {
|
|
79
|
+
const cores = Math.max(1, navigator.hardwareConcurrency)
|
|
80
|
+
if (process.platform !== 'linux') return cores
|
|
81
|
+
const quota = cgroupCpuQuota(probe)
|
|
82
|
+
return quota === undefined ? cores : Math.max(1, Math.min(cores, Math.ceil(quota)))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ratio(quota: number, period: number): number | undefined {
|
|
86
|
+
return Number.isFinite(quota) && Number.isFinite(period) && quota > 0 && period > 0
|
|
87
|
+
? quota / period
|
|
88
|
+
: undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseLimit(
|
|
92
|
+
raw: string | undefined,
|
|
93
|
+
parse: (raw: string) => number | undefined,
|
|
94
|
+
): number | undefined {
|
|
95
|
+
if (raw === undefined) return undefined
|
|
96
|
+
const n = parse(raw)
|
|
97
|
+
return n !== undefined && Number.isFinite(n) && n > 0 ? n : undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function read(dir: string, file: string): string | undefined {
|
|
101
|
+
const p = path.join(dir, file)
|
|
102
|
+
if (!existsSync(p)) return undefined
|
|
103
|
+
try {
|
|
104
|
+
return readFileSync(p, 'utf8').trim()
|
|
105
|
+
} catch {
|
|
106
|
+
// A level that cannot be read bounds nothing.
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The minimum over every level from this process's cgroup (for
|
|
113
|
+
* `controller`) up to the hierarchy's root. The v1 mount is
|
|
114
|
+
* `<root>/<controller>`; v2 is the one unified tree at `<root>`. A
|
|
115
|
+
* membership path that escapes the root (a foreign file) is not walked.
|
|
116
|
+
*/
|
|
117
|
+
function tightest(
|
|
118
|
+
probe: CgroupProbe,
|
|
119
|
+
controller: 'memory' | 'cpu',
|
|
120
|
+
readers: { v2: (dir: string) => number | undefined; v1: (dir: string) => number | undefined },
|
|
121
|
+
): number | undefined {
|
|
122
|
+
const root = probe.root ?? '/sys/fs/cgroup'
|
|
123
|
+
let membership: string
|
|
124
|
+
try {
|
|
125
|
+
membership = readFileSync(probe.procSelfCgroup ?? '/proc/self/cgroup', 'utf8')
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
const candidates: number[] = []
|
|
130
|
+
for (const line of membership.split('\n')) {
|
|
131
|
+
const parts = line.split(':')
|
|
132
|
+
if (parts.length < 3) continue
|
|
133
|
+
const controllers = parts[1]!
|
|
134
|
+
const cgroupPath = parts.slice(2).join(':')
|
|
135
|
+
if (controllers === '') {
|
|
136
|
+
walkUp(path.join(root, cgroupPath), root, readers.v2, candidates)
|
|
137
|
+
} else if (controllers.split(',').includes(controller)) {
|
|
138
|
+
const base = path.join(root, controller)
|
|
139
|
+
walkUp(path.join(base, cgroupPath), base, readers.v1, candidates)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return candidates.length === 0 ? undefined : Math.min(...candidates)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function walkUp(
|
|
146
|
+
from: string,
|
|
147
|
+
stopAt: string,
|
|
148
|
+
readLevel: (dir: string) => number | undefined,
|
|
149
|
+
into: number[],
|
|
150
|
+
): void {
|
|
151
|
+
let dir = path.resolve(from)
|
|
152
|
+
const stop = path.resolve(stopAt)
|
|
153
|
+
if (dir !== stop && !dir.startsWith(stop + path.sep)) return
|
|
154
|
+
for (;;) {
|
|
155
|
+
const n = readLevel(dir)
|
|
156
|
+
if (n !== undefined) into.push(n)
|
|
157
|
+
if (dir === stop) return
|
|
158
|
+
dir = path.dirname(dir)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -19,3 +19,52 @@ export function editDistance(a: string, b: string): number {
|
|
|
19
19
|
}
|
|
20
20
|
return Math.min(prev[b.length]!, 3)
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The ONE near-miss rule every "did you mean" hint applies: the closest
|
|
25
|
+
* candidate within two edits of `name`, or undefined — an exact match is
|
|
26
|
+
* not a hint, and anything further would guess rather than help. Task
|
|
27
|
+
* names, `pkg#task` halves, project names, flags and verbs all go through
|
|
28
|
+
* here so a typo is hinted the same way wherever it is typed.
|
|
29
|
+
*/
|
|
30
|
+
export function nearest(
|
|
31
|
+
name: string,
|
|
32
|
+
candidates: Iterable<string>,
|
|
33
|
+
maxEdits = 2,
|
|
34
|
+
): string | undefined {
|
|
35
|
+
let best: string | undefined
|
|
36
|
+
let bestD = maxEdits + 1
|
|
37
|
+
for (const c of candidates) {
|
|
38
|
+
const d = editDistance(name, c)
|
|
39
|
+
if (d < bestD) {
|
|
40
|
+
bestD = d
|
|
41
|
+
best = c
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return best === name ? undefined : best
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Up to `limit` candidates worth offering for `name`: the nearest by edit
|
|
49
|
+
* distance first, then any that contain it (or that it contains), case-
|
|
50
|
+
* insensitively — the inspection verbs (`why`, `prune`) list a few rather
|
|
51
|
+
* than pick one, since a partial name is as common a query there as a
|
|
52
|
+
* typo. Never includes `name` itself.
|
|
53
|
+
*/
|
|
54
|
+
export function nearMatches(name: string, candidates: Iterable<string>, limit = 3): string[] {
|
|
55
|
+
const all = [...new Set(candidates)].filter((c) => c !== name)
|
|
56
|
+
const q = name.toLowerCase()
|
|
57
|
+
const byDistance = all
|
|
58
|
+
.map((c) => [c, editDistance(name, c)] as const)
|
|
59
|
+
.filter(([, d]) => d < 3)
|
|
60
|
+
.sort((a, b) => a[1] - b[1])
|
|
61
|
+
.map(([c]) => c)
|
|
62
|
+
const out = [...byDistance]
|
|
63
|
+
for (const c of all) {
|
|
64
|
+
if (out.length >= limit) break
|
|
65
|
+
if (out.includes(c)) continue
|
|
66
|
+
const n = c.toLowerCase()
|
|
67
|
+
if (n.includes(q) || q.includes(n)) out.push(c)
|
|
68
|
+
}
|
|
69
|
+
return out.slice(0, limit)
|
|
70
|
+
}
|
package/src/util/index.ts
CHANGED
|
@@ -5,9 +5,17 @@ export { isUserError, UserError } from './errors.js'
|
|
|
5
5
|
export { xxh3, xxh3hex } from './hash.js'
|
|
6
6
|
export { mark, printTimings, span } from './timing.js'
|
|
7
7
|
export { clampInt, MAX_TIMEOUT_MS, parseDecimalInt } from './num.js'
|
|
8
|
-
export { relPosix, staticPrefix, wholeSubtreePrefixes } from './paths.js'
|
|
9
|
-
export { settleWithin, teardownTimeoutMs } from './settle.js'
|
|
8
|
+
export { normalizeGlob, relPosix, staticPrefix, wholeSubtreePrefixes } from './paths.js'
|
|
9
|
+
export { killGraceMs, settleWithin, teardownTimeoutMs } from './settle.js'
|
|
10
10
|
export { parseSize } from './size.js'
|
|
11
|
+
export {
|
|
12
|
+
cgroupCpuQuota,
|
|
13
|
+
cgroupMemoryLimitBytes,
|
|
14
|
+
machineMemoryBytes,
|
|
15
|
+
machineParallelism,
|
|
16
|
+
type CgroupProbe,
|
|
17
|
+
} from './cgroup.js'
|
|
11
18
|
export { appendTail, createTail, resetTail, tailText, type Tail } from './tail.js'
|
|
12
19
|
export { ulid } from './ulid.js'
|
|
13
|
-
export { editDistance } from './edit-distance.js'
|
|
20
|
+
export { editDistance, nearMatches, nearest } from './edit-distance.js'
|
|
21
|
+
export { CORE_VERBS, DISPATCHED_VERBS, MOVED_VERBS } from './verbs.js'
|
package/src/util/paths.ts
CHANGED
|
@@ -26,7 +26,10 @@ export function relPosix(from: string, to: string): string {
|
|
|
26
26
|
* how the two would disagree about what a prefix is.
|
|
27
27
|
*/
|
|
28
28
|
export function staticPrefix(glob: string): string {
|
|
29
|
-
|
|
29
|
+
// A brace set is a wildcard too: `{dist,build}/**` reaches either dir,
|
|
30
|
+
// and reading it as the literal directory `{dist,build}` gave the
|
|
31
|
+
// sandbox baseline a prefix that exists nowhere (2026-09-10).
|
|
32
|
+
const wildcardIdx = glob.search(/[*?[\]{}]/)
|
|
30
33
|
if (wildcardIdx === -1) return glob
|
|
31
34
|
const head = glob.slice(0, wildcardIdx)
|
|
32
35
|
const lastSep = head.lastIndexOf('/')
|
|
@@ -47,7 +50,7 @@ export function staticPrefix(glob: string): string {
|
|
|
47
50
|
export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null {
|
|
48
51
|
if (globs.length === 0) return null
|
|
49
52
|
const out: string[] = []
|
|
50
|
-
for (const g of globs) {
|
|
53
|
+
for (const g of globs.map(normalizeGlob)) {
|
|
51
54
|
const m = /^([^*?[\]{}!]+?)\/\*\*$/.exec(g)
|
|
52
55
|
if (m === null) return null
|
|
53
56
|
const dir = m[1]!.replace(/\/+$/, '')
|
|
@@ -57,3 +60,21 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
|
|
|
57
60
|
}
|
|
58
61
|
return [...new Set(out)]
|
|
59
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The spellings a reader, Turbo and `.gitignore` all accept but a matcher
|
|
66
|
+
* fed the raw string turns into NOTHING — and a task keyed on nothing
|
|
67
|
+
* replays old outputs as a green hit (2026-09-10, probed one by one):
|
|
68
|
+
* a leading `./`, an inner `/./` segment, a doubled `//`, and a trailing
|
|
69
|
+
* `/` on a pattern (`src/*\/` means the trees under `src`, so it becomes
|
|
70
|
+
* `src/*\/**`; a trailing slash on a LITERAL is `asTrees`' job). Applied
|
|
71
|
+
* after an optional `!`; a bare `.` is the empty entry the schema refuses.
|
|
72
|
+
*/
|
|
73
|
+
export function normalizeGlob(glob: string): string {
|
|
74
|
+
const neg = glob.startsWith('!')
|
|
75
|
+
let g = neg ? glob.slice(1) : glob
|
|
76
|
+
g = g.replace(/\/{2,}/g, '/').replace(/(^|\/)(\.\/)+/g, '$1')
|
|
77
|
+
if (g === '.') g = ''
|
|
78
|
+
if (/[*?[\]{}]/.test(g) && g.endsWith('/')) g = `${g.replace(/\/+$/, '')}/**`
|
|
79
|
+
return neg ? `!${g}` : g
|
|
80
|
+
}
|