@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -10,13 +10,15 @@
10
10
  // rendering-oriented `WireEvent` stream.
11
11
  //
12
12
  // A sink is observe-only BY CONSTRUCTION: its only input is a plain-data
13
- // record (one object every sink receives, not frozen); its `TelemetryContext` carries read-only metadata and NO mutable
13
+ // record (one object every sink receives, frozen once, deep: a sink that
14
+ // cleared a `task.end`'s `failedAttempts` cleared the outcome's own and
15
+ // crashed the run's failure log); its `TelemetryContext` carries read-only metadata and NO mutable
14
16
  // run handle (no bus, no Cache, no RunRequest). There is no API path from a
15
17
  // sink back into scheduling/caching/exec — telemetry provably cannot change
16
18
  // what or how tasks run. Contrast `cache`/`executor`, which return objects
17
19
  // core calls INTO; those are the behavior capabilities, kept separate.
18
20
 
19
- import { sampleTrees, type MachineReport } from '../exec/index.js'
21
+ import { sampleTrees, type MachineReport, type WorkerAdvice } from '../exec/index.js'
20
22
  import {
21
23
  type CacheSource,
22
24
  deriveCacheSource,
@@ -34,7 +36,8 @@ import {
34
36
  teardownTimeoutMs,
35
37
  thrownMessage,
36
38
  } from '../util/index.js'
37
- import type { RunEvent, RunEventSubscriber } from '../report/index.js'
39
+ import { neverStarted, type RunEvent, type RunEventSubscriber } from '../report/index.js'
40
+ import { freezeDeep } from './read-only.js'
38
41
 
39
42
  /** Bumped when the record shape changes. Readers MUST check `v`. */
40
43
  export const TELEMETRY_SCHEMA_VERSION = 4
@@ -256,14 +259,20 @@ export interface RunSummaryRecord {
256
259
  startedAt: number
257
260
  endedAt: number
258
261
  totalDurationMs: number
262
+ /** Every task the run had, as the terminal's total: `tasks` plus `notRunCount`. */
259
263
  taskCount: number
260
264
  failedCount: number
261
265
  /**
262
- * Tasks a shutdown signal or an embedder's abort killed (v3). They are
263
- * not in `tasks`, which holds real runs only, so without this a stopped
264
- * run read as a failure with nothing failed (item 851).
266
+ * Tasks a shutdown signal or an embedder's abort killed once started (v3),
267
+ * each in `tasks` as `aborted`: without the count a stopped run read as a
268
+ * failure with nothing failed (item 851).
265
269
  */
266
270
  abortedCount: number
271
+ /**
272
+ * Tasks the stop reached before they started: the terminal's `not run`.
273
+ * Not in `tasks`, since they never ran. Absent when none. Additive.
274
+ */
275
+ notRunCount?: number
267
276
  hitCount: number
268
277
  /** Hits by the layer that answered, up-to-date ones included. */
269
278
  hitLocalCount: number
@@ -301,6 +310,8 @@ export interface RunSummaryRecord {
301
310
  * (`TaskExecutor.idealMs`), beside `totalDurationMs`. Additive.
302
311
  */
303
312
  idealMs?: number
313
+ /** The workers that would have run it best (`TaskExecutor.advice`). Additive. */
314
+ advice?: readonly WorkerAdvice[]
304
315
  }
305
316
 
306
317
  /** One stage of a run, a wall window in epoch ms. */
@@ -316,8 +327,9 @@ export interface RunStage {
316
327
  * computed: both a local `run()` and the distributed controller build their
317
328
  * `TaskTelemetry[]` (each via `deriveCacheSource`) and call this, so a
318
329
  * distributed run and a local run produce byte-identical summaries and land in
319
- * the same ingest. The per-task tallies (taskCount / failedCount /
320
- * hitLocal|Remote|Count) derive from `tasks`; `totalDurationMs` (wall time) and
330
+ * the same ingest. The per-task tallies (failedCount /
331
+ * hitLocal|Remote|Count) derive from `tasks`, and `taskCount` from `tasks`
332
+ * plus `notRunCount`; `totalDurationMs` (wall time) and
321
333
  * `exitOk` (the run's overall verdict — which counts skipped tasks beyond the
322
334
  * recorded task list) are run-level facts and are passed in.
323
335
  */
@@ -330,10 +342,12 @@ export function assembleRunSummary(
330
342
  totalDurationMs: number
331
343
  exitOk: boolean
332
344
  abortedCount: number
345
+ notRunCount?: number
333
346
  stages?: readonly RunStage[]
334
347
  uploads?: { count: number; bytes: number; ms: number; failed: number }
335
348
  machines?: readonly MachineReport[]
336
349
  idealMs?: number | undefined
350
+ advice?: readonly WorkerAdvice[]
337
351
  },
338
352
  ): RunSummaryRecord {
339
353
  let failedCount = 0
@@ -357,9 +371,14 @@ export function assembleRunSummary(
357
371
  startedAt: timing.startedAt,
358
372
  endedAt: timing.endedAt,
359
373
  totalDurationMs: timing.totalDurationMs,
360
- taskCount: tasks.length,
374
+ // The terminal's total: the tasks a stop reached first are not in
375
+ // `tasks`, yet a summary of 2 beside a terminal's 3 read as a lost task.
376
+ taskCount: tasks.length + (timing.notRunCount ?? 0),
361
377
  failedCount,
362
378
  abortedCount: timing.abortedCount,
379
+ ...(timing.notRunCount !== undefined && timing.notRunCount > 0
380
+ ? { notRunCount: timing.notRunCount }
381
+ : {}),
363
382
  hitCount: hitLocalCount + hitRemoteCount,
364
383
  hitLocalCount,
365
384
  hitRemoteCount,
@@ -376,10 +395,15 @@ export function assembleRunSummary(
376
395
  ? { machines: timing.machines }
377
396
  : {}),
378
397
  ...(timing.idealMs !== undefined ? { idealMs: timing.idealMs } : {}),
398
+ ...(timing.advice !== undefined && timing.advice.length > 0 ? { advice: timing.advice } : {}),
379
399
  }
380
400
  }
381
401
 
382
- /** A telemetry consumer. Observe-only: receives records, holds no run handle. */
402
+ /**
403
+ * A telemetry consumer. Observe-only: receives records, frozen, holds no
404
+ * run handle. A write to a record throws, and the sink is switched off for
405
+ * the run as for any throw.
406
+ */
383
407
  export interface TelemetrySink {
384
408
  readonly name?: string
385
409
  /**
@@ -391,7 +415,11 @@ export interface TelemetrySink {
391
415
  readonly wants?: ReadonlyArray<TelemetryRecord['kind']>
392
416
  /** A streaming record. MUST return promptly — buffer; do NOT await I/O. */
393
417
  onRecord?(record: TelemetryRecord): void
394
- /** The per-run summary, at run:end. MUST return promptly. */
418
+ /**
419
+ * The per-run summary, once at run:end; a requested persistent task the
420
+ * foreground keeps sends it when that task exits, so it reads the run's
421
+ * final verdict. MUST return promptly.
422
+ */
395
423
  onRunSummary?(summary: RunSummaryRecord): void
396
424
  /**
397
425
  * Drain buffered data. Awaited at run:end under a deadline — a sink that
@@ -429,6 +457,12 @@ export interface TelemetrySource {
429
457
  * each as the step `<plugin>:telemetry` through `step`.
430
458
  */
431
459
  flush(step?: <T>(id: string, work: () => Promise<T>) => Promise<T>): Promise<void>
460
+ /**
461
+ * Send the held `task.end` of each persistent task not in `keep`, read
462
+ * from `final` (by id): a server's outcome can change after its ready,
463
+ * when it dies before the run stops it or ends a kept session.
464
+ */
465
+ endServers(final: readonly TaskOutcome[], keep?: ReadonlySet<string>): void
432
466
  /**
433
467
  * Sample this task's process tree every `SAMPLE_MS` until the returned
434
468
  * function is called, the root exits, or the run ends. Present only when
@@ -598,6 +632,9 @@ export function createTelemetrySource(args: {
598
632
  }
599
633
 
600
634
  function deliver(record: TelemetryRecord): void {
635
+ // Once, for every sink: a `task.end` holds the outcome's own arrays, and
636
+ // a sink that cleared `failedAttempts` crashed the run's failure log.
637
+ freezeDeep(record)
601
638
  for (const sink of sinks) {
602
639
  if (disabled.has(sink) || sink.onRecord === undefined) continue
603
640
  const kinds = sink.wants ?? DEFAULT_KINDS
@@ -663,6 +700,16 @@ export function createTelemetrySource(args: {
663
700
  case 'task:complete': {
664
701
  const { node, outcome } = event
665
702
  if (node.config.exec === undefined) return // group task
703
+ // The stop reached it first: no task.start, and the summary counts
704
+ // it in `notRunCount`, not `tasks`, so no task.end either.
705
+ if (neverStarted(outcome)) return
706
+ // A server's end waits for its final outcome (`endServers`): one
707
+ // task.end per task, and the one sent at ready read `success` over
708
+ // a crash after it.
709
+ if (node.config.exec.persistent !== undefined) {
710
+ heldEnds.set(node.id, { ts, outcome })
711
+ return
712
+ }
666
713
  deliver({
667
714
  v: TELEMETRY_SCHEMA_VERSION,
668
715
  kind: 'task.end',
@@ -683,10 +730,26 @@ export function createTelemetrySource(args: {
683
730
  }
684
731
  }
685
732
 
733
+ const heldEnds = new Map<string, { ts: number; outcome: TaskOutcome }>()
686
734
  return {
687
735
  subscriber,
688
736
  ...(wantsSample ? { track } : {}),
737
+ endServers(final, keep) {
738
+ for (const [id, held] of heldEnds) {
739
+ if (keep?.has(id) === true) continue
740
+ heldEnds.delete(id)
741
+ const outcome = final.find((o) => o.node.id === id) ?? held.outcome
742
+ deliver({
743
+ v: TELEMETRY_SCHEMA_VERSION,
744
+ kind: 'task.end',
745
+ runId,
746
+ ts: held.ts,
747
+ ...taskTelemetryOf(outcome),
748
+ })
749
+ }
750
+ },
689
751
  emitSummary(summary: RunSummaryRecord): void {
752
+ freezeDeep(summary)
690
753
  for (const sink of sinks) {
691
754
  if (disabled.has(sink) || sink.onRunSummary === undefined) continue
692
755
  try {
@@ -708,9 +771,12 @@ export function createTelemetrySource(args: {
708
771
  // accessed_at bumps and every later plugin's teardown lost with it.
709
772
  // Sinks race concurrently, so each still gets the whole budget.
710
773
  const deadline = new AbortController()
774
+ // The deadline's warning names the sinks still out, as a throw names its own.
775
+ const pending = new Set<TelemetrySink>()
711
776
  const all = Promise.all(
712
777
  sinks.map(async (sink) => {
713
778
  if (disabled.has(sink) || sink.flush === undefined) return
779
+ pending.add(sink)
714
780
  try {
715
781
  await step(`${args.owners?.get(sink) ?? label(sink)}:telemetry`, () =>
716
782
  sink.flush!(deadline.signal),
@@ -719,12 +785,17 @@ export function createTelemetrySource(args: {
719
785
  // A flush failure can never break the run — but it still costs
720
786
  // this sink its export, so say so rather than dropping it.
721
787
  warn?.(`[vx] telemetry sink '${label(sink)}' failed to flush: ${thrownMessage(err)}`)
788
+ } finally {
789
+ pending.delete(sink)
722
790
  }
723
791
  }),
724
792
  )
725
793
  if (!(await settleWithin(all, ms))) {
726
794
  deadline.abort(new Error(`telemetry flush deadline (${ms}ms)`))
727
- warn?.(`[vx] telemetry flush timed out after ${ms}ms; buffered records lost`)
795
+ const names = [...pending].map((s) => `'${label(s)}'`).join(', ')
796
+ warn?.(
797
+ `[vx] telemetry flush timed out after ${ms}ms (sink${pending.size === 1 ? '' : 's'} ${names}); buffered records lost`,
798
+ )
728
799
  // A sink told of the abort says what it lost as its request ends; the
729
800
  // run ended first and that line was lost (vx-otel, on CI). Briefly.
730
801
  await settleWithin(all, ABORT_GRACE_MS)
@@ -763,7 +834,8 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
763
834
  project: o.node.projectName,
764
835
  task: o.node.taskName,
765
836
  status: o.status,
766
- cacheSource: deriveCacheSource(o.status),
837
+ // A hit whose restore failed is in no cache column: neither a miss nor a hit.
838
+ cacheSource: o.restoreFailed === true ? 'none' : deriveCacheSource(o.status),
767
839
  exitCode: o.exitCode,
768
840
  durationMs: o.durationMs,
769
841
  }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  selectFoldedDeps,
3
+ type StandIns,
3
4
  type FoldCandidate,
4
5
  type TaskNode,
5
6
  type TaskOutcome,
@@ -72,10 +73,11 @@ export function filterUpstreamHashes(
72
73
  filter: readonly string[] | undefined,
73
74
  selfProjectName: string,
74
75
  selfTaskId: string,
76
+ standIns?: StandIns,
75
77
  ): Array<[upstreamTaskId: string, hash: string]> {
76
78
  const candidates: FoldCandidate[] = []
77
79
  for (const u of upstream) if (u.hash) candidates.push({ node: u.node, unit: u.hash })
78
- return selectFoldedDeps(candidates, filter, selfProjectName, selfTaskId).map((c) => [
80
+ return selectFoldedDeps(candidates, filter, selfProjectName, selfTaskId, standIns).map((c) => [
79
81
  c.node.id,
80
82
  c.unit,
81
83
  ])
@@ -8,18 +8,18 @@
8
8
 
9
9
  import { exitSignal } from '../exec/index.js'
10
10
  import type { InputChanges, TaskNode, TaskOutcome } from '../graph/index.js'
11
- import { isGroupTask } from '../graph/index.js'
11
+ import { isGroupTask, isKeyFailed, markOutputsFailed, outputsFailedOf } from '../graph/index.js'
12
12
  import { heldSecrets, maskedCommand, type SecretMask, secretMask } from '../util/index.js'
13
13
  import type { Logger } from './logger.js'
14
14
  import type { RunContext } from './summary.js'
15
15
 
16
16
  /**
17
- * A task no cache answered for: it declares no `cache`, or the run's policy
17
+ * A task no cache answered for: it has no `cache: true`, or the run's policy
18
18
  * read and wrote nothing. Its run is `no-cache`, never a miss, on every
19
19
  * surface — `--dry`'s plan, the frame, the footer, the run history.
20
20
  */
21
21
  export function ranNoCache(o: { node: TaskNode; cacheOff?: boolean }): boolean {
22
- return o.node.config.cache === undefined || o.cacheOff === true
22
+ return o.node.config.cache !== true || o.cacheOff === true
23
23
  }
24
24
 
25
25
  /** Payload of the `run:start` event — mirrors the Logger.runStart hook. */
@@ -41,7 +41,8 @@ export interface RunStartInfo {
41
41
  * refs — zero-copy, because the default terminal subscriber runs on the
42
42
  * same thread and reads those objects directly. Crossing a thread or RPC
43
43
  * needs the serializable WireEvent form (see `projectNode` / `toWireEvent`
44
- * below); that mapping is the boundary's job, not the producer's.
44
+ * below); that mapping is the boundary's job, not the producer's. A
45
+ * plugin gets read-only views of them (orchestrator/read-only.ts).
45
46
  */
46
47
  export type RunEvent =
47
48
  | { kind: 'run:start'; info: RunStartInfo }
@@ -281,8 +282,10 @@ export interface OutcomeView {
281
282
  * and counting groups as executed tasks drifts from the terminal.
282
283
  */
283
284
  isGroup?: boolean
284
- /** Executed with no `cache` block: never consulted the cache, not a miss. */
285
+ /** Executed without `cache: true`: never consulted the cache, not a miss. */
285
286
  noCache?: boolean
287
+ /** A failed hit whose restore threw (see `TaskOutcome`): neither a miss nor a hit. */
288
+ restoreFailed?: true
286
289
  /** Cache hits only: the exec time the entry was stored with — the work
287
290
  * this hit skipped, as opposed to `durationMs`, the restore it cost. */
288
291
  storedDurationMs?: number
@@ -344,12 +347,18 @@ export function outcomeWord(o: Pick<OutcomeView, 'status' | 'restored'>): string
344
347
  }
345
348
  }
346
349
 
347
- /** `outcomeWord` with the exit code a failure carries: `failed (exit 9)`. */
350
+ /**
351
+ * `outcomeWord` with the exit code a failure carries: `failed (exit 9)`.
352
+ * A hit whose restore failed ran nothing, so it has no exit to name, nor
353
+ * does a task whose key could not be derived (`isKeyFailed`) or whose
354
+ * output clean or save vx refused (`outputsFailedOf`): the step reads.
355
+ */
348
356
  export function outcomeLabel(
349
357
  o: Pick<
350
358
  OutcomeView,
351
359
  | 'status'
352
360
  | 'restored'
361
+ | 'restoreFailed'
353
362
  | 'exitCode'
354
363
  | 'timedOut'
355
364
  | 'sandboxViolations'
@@ -359,6 +368,11 @@ export function outcomeLabel(
359
368
  | 'otherPlatform'
360
369
  >,
361
370
  ): string {
371
+ if (o.status === 'failed' && o.restoreFailed === true) return 'failed (cache restore failed)'
372
+ if (o.status === 'failed' && isKeyFailed(o)) return 'failed (cache key not derived)'
373
+ const step = o.status === 'failed' ? outputsFailedOf(o) : undefined
374
+ if (step !== undefined)
375
+ return step === 'save' ? 'failed (cache save failed)' : 'failed (output clean failed)'
362
376
  if (o.status === 'failed')
363
377
  return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady, o.inputMoved)
364
378
  if (o.status === 'skipped') return skippedLabel(o)
@@ -449,6 +463,9 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
449
463
  }
450
464
  if (isGroupTask(outcome.node)) view.isGroup = true
451
465
  else if (ranNoCache(outcome)) view.noCache = true
466
+ if (outcome.restoreFailed === true) view.restoreFailed = true
467
+ const step = outputsFailedOf(outcome)
468
+ if (step !== undefined) markOutputsFailed(view, step)
452
469
  if (outcome.storedDurationMs !== undefined) view.storedDurationMs = outcome.storedDurationMs
453
470
  if (outcome.storedCpuMs !== undefined) view.storedCpuMs = outcome.storedCpuMs
454
471
  if (outcome.storedPeakRssBytes !== undefined) view.storedPeakRssBytes = outcome.storedPeakRssBytes
@@ -33,6 +33,11 @@ export interface BlockedTask {
33
33
  by: string
34
34
  }
35
35
 
36
+ /**
37
+ * One pass over what is unfinished. Called again with the same `finished`
38
+ * set, that set has only grown: the pass reads only what the last one left
39
+ * unfinished. Another set starts over.
40
+ */
36
41
  export type Forecast = (
37
42
  finished: ReadonlySet<string>,
38
43
  running: ReadonlyMap<string, number>,
@@ -78,68 +83,129 @@ export function createForecast(
78
83
  }
79
84
  }
80
85
  for (const n of nodes.values()) place(n)
81
- const cost = new Map<string, number>()
82
- for (const n of order) {
83
- const p = isGroupTask(n) ? undefined : p50s.get(n.id)
84
- if (p !== undefined) cost.set(n.id, p)
86
+ // Each node by its place in `order`, its facts in arrays. A pass built
87
+ // Maps of the whole graph and read every finished task again, and each
88
+ // task event forces a frame, each frame a pass: a 20,000-task run spent
89
+ // ~1.6 s of its main thread on them on a terminal (2026-11-07).
90
+ const size = order.length
91
+ const position = new Map(order.map((n, i) => [n.id, i]))
92
+ const deps = order.map((n) => {
93
+ const at: number[] = []
94
+ // A dependency outside the graph never has an end: leaving it out changes nothing.
95
+ for (const d of n.deps) {
96
+ const i = position.get(d)
97
+ if (i !== undefined) at.push(i)
98
+ }
99
+ return at
100
+ })
101
+ const ids = order.map((n) => n.id)
102
+ const group = Uint8Array.from(order, (n) => (isGroupTask(n) ? 1 : 0))
103
+ // NaN: no p50.
104
+ const cost = new Float64Array(size)
105
+ const ranks = new Float64Array(size)
106
+ for (let i = 0; i < size; i++) {
107
+ const n = order[i]!
108
+ cost[i] = (group[i] ? undefined : p50s.get(n.id)) ?? NaN
109
+ ranks[i] = rank.get(n.id) ?? 0
85
110
  }
86
111
  const workers = Math.max(1, concurrency)
87
112
  // The scheduler's own baseline ranking (`rank`), most dependents first,
88
113
  // ties in graph order.
89
- const position = new Map(order.map((n, i) => [n.id, i]))
90
- const before = (a: string, b: string): boolean => {
91
- const d = (rank.get(a) ?? 0) - (rank.get(b) ?? 0)
92
- return d !== 0 ? d > 0 : position.get(a)! < position.get(b)!
114
+ const before = (a: number, b: number): boolean => {
115
+ const d = ranks[a]! - ranks[b]!
116
+ return d !== 0 ? d > 0 : a < b
93
117
  }
118
+ // Reused by every pass. `end` NaN: no end yet (finished, or not reached);
119
+ // `since` NaN: not running; `head` -1: no running task its chain waits on.
120
+ const end = new Float64Array(size)
121
+ const since = new Float64Array(size)
122
+ const head = new Int32Array(size)
123
+ // What the last pass left unfinished, in order: a pass reads only these,
124
+ // not every finished task again. Rebuilt when `finished` is another set
125
+ // or shrank; the logger's only grows.
126
+ const live = new Int32Array(size)
127
+ let liveCount = -1
128
+ let lastFinished: ReadonlySet<string> | undefined
129
+ let lastSize = 0
94
130
 
95
131
  return (finished, running, nowMs) => {
132
+ if (finished !== lastFinished || finished.size < lastSize || liveCount < 0) {
133
+ for (let i = 0; i < size; i++) live[i] = i
134
+ liveCount = size
135
+ }
136
+ let kept = 0
137
+ for (let r = 0; r < liveCount; r++) {
138
+ const i = live[r]!
139
+ if (!finished.has(ids[i]!)) live[kept++] = i
140
+ }
141
+ liveCount = kept
142
+ lastFinished = finished
143
+ lastSize = finished.size
144
+ end.fill(NaN)
145
+ since.fill(NaN)
146
+ head.fill(-1)
147
+ for (const [id, at] of running) {
148
+ const i = position.get(id)
149
+ if (i !== undefined) since[i] = at
150
+ }
96
151
  let waiting = 0
97
- const end = new Map<string, number>()
98
- // The running task each node's longest chain waits on.
99
- const head = new Map<string, string>()
100
152
  let chain = 0
101
- let chainEnd: string | undefined
153
+ let chainEnd = -1
102
154
  let work = 0
103
155
  let known = false
104
156
  // The best `workers` blocked tasks, kept sorted as the pass meets them.
105
157
  const held: BlockedTask[] = []
158
+ const heldAt: number[] = []
106
159
  const blockedBy = new Map<string, number>()
107
- for (const n of order) {
108
- if (finished.has(n.id)) continue
160
+ for (let r = 0; r < liveCount; r++) {
161
+ const i = live[r]!
162
+ const ds = deps[i]!
109
163
  let start = 0
110
- let via: string | undefined
111
- for (const d of n.deps) {
112
- const e = end.get(d)
113
- if (e !== undefined && (via === undefined || e > start)) {
164
+ let via = -1
165
+ for (const d of ds) {
166
+ const e = end[d]!
167
+ if (e === e && (via < 0 || e > start)) {
114
168
  start = e
115
169
  via = d
116
170
  }
117
171
  }
118
- const since = running.get(n.id)
119
- if (since !== undefined) head.set(n.id, n.id)
120
- else if (via !== undefined && head.has(via)) head.set(n.id, head.get(via)!)
121
- const by =
122
- since === undefined ? (head.get(n.id) ?? n.deps.find((d) => end.has(d))) : undefined
123
- if (by !== undefined && !isGroupTask(n)) {
124
- waiting++
125
- let at = held.length
126
- while (at > 0 && before(n.id, held[at - 1]!.id)) at--
127
- if (at < workers) {
128
- held.splice(at, 0, { id: n.id, by })
129
- if (held.length > workers) held.pop()
172
+ const s = since[i]!
173
+ const runs = s === s
174
+ if (runs) head[i] = i
175
+ else if (via >= 0 && head[via]! >= 0) head[i] = head[via]!
176
+ if (!runs && !group[i]) {
177
+ let by = head[i]!
178
+ if (by < 0)
179
+ for (const d of ds)
180
+ if (end[d] === end[d]) {
181
+ by = d
182
+ break
183
+ }
184
+ if (by >= 0) {
185
+ waiting++
186
+ let at = held.length
187
+ while (at > 0 && before(i, heldAt[at - 1]!)) at--
188
+ if (at < workers) {
189
+ held.splice(at, 0, { id: ids[i]!, by: ids[by]! })
190
+ heldAt.splice(at, 0, i)
191
+ if (held.length > workers) {
192
+ held.pop()
193
+ heldAt.pop()
194
+ }
195
+ }
130
196
  }
131
197
  }
132
- const p = cost.get(n.id)
198
+ const p = cost[i]!
133
199
  let own = 0
134
- if (p !== undefined) {
200
+ if (p === p) {
135
201
  known = true
136
- own = since === undefined ? p : Math.max(0, p - (nowMs - since))
202
+ own = runs ? Math.max(0, p - (nowMs - s)) : p
137
203
  }
138
204
  const e = start + own
139
- end.set(n.id, e)
140
- if (e > chain || chainEnd === undefined) {
205
+ end[i] = e
206
+ if (e > chain || chainEnd < 0) {
141
207
  chain = e
142
- chainEnd = n.id
208
+ chainEnd = i
143
209
  }
144
210
  work += own
145
211
  }
@@ -148,7 +214,7 @@ export function createForecast(
148
214
  if (d !== undefined && d > 0) blockedBy.set(id, d)
149
215
  }
150
216
  if (!known) return { waiting, blocked: held, blockedBy }
151
- const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
217
+ const critical = chainEnd < 0 || head[chainEnd]! < 0 ? undefined : ids[head[chainEnd]!]!
152
218
  return {
153
219
  etaMs: Math.max(chain, work / workers),
154
220
  ...(critical !== undefined ? { critical } : {}),