@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.
Files changed (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. 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 cache misses. */
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
- if (context.cpuBudget !== undefined) info.push(`cpu budget ${context.cpuBudget}`)
252
- if (context.memBudget !== undefined && Number.isFinite(context.memBudget))
253
- info.push(`mem budget ${formatBudgetBytes(context.memBudget)}`)
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.resources` (cores, memory AND the
268
- * image a task must be matched to) and `exec.remote` are pure PLACEMENT
269
- * hints with zero effect on outputs, so they're stripped — tuning a
270
- * reservation, pinning a task to this machine, or naming the container it
271
- * runs in never busts a cache. `remote` in particular must not: the whole
272
- * contract of a remote executor is that the same command over the same
273
- * inputs produces the same outputs, so a key that moved when placement
274
- * changed would split a laptop from a worker pool over nothing. `image`
275
- * rides the same rule one step further: a task that ran locally and the
276
- * same task on a worker must share an entry, and a task whose output really
277
- * does depend on its toolchain says so with `cache.inputs.runtime`, which
278
- * works on both. A config that declares neither takes the fast path and stringifies byte-identically to before the fields existed
279
- * (why this needs no CACHE_VERSION bump). `timeout`/`retries` stay folded
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?.resources === undefined && cfg.exec?.remote === undefined) return cfg
285
- const { resources: _resources, remote: _remote, ...execRest } = cfg.exec
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 await cache.hashFile(filePath)
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 — the cloud client sink (local
3
- // runs), the cloud serve sink (delegated runs), the dist scheduler, and the
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: cloud's `POST /v1/ingest/logs` accepts
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. It is also what the STORE does when its own run budget runs
260
- * out (`Analytics.ingestLogs` slices to empty and adds the remainder to
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, against the 16 MiB `/v1/ingest/logs` cap. A run evicting past ~66k
266
- * tasks would 413 the batch — remote (6x the scale target), and it does not
267
- * touch the connected default, which ships per task as it finishes.
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
- return sink as TelemetrySink
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 { OutputFingerprint, TaskStatus, VerifyVerdict } from '../graph/index.js'
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`/`verify` precedent). */
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/verify beyond the
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
- const wildcardIdx = glob.search(/[*?[\]]/)
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
+ }