@vzn/vx 0.0.649 → 0.0.651

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 (122) hide show
  1. package/README.md +5 -20
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/src/bin.ts +9 -31
  5. package/src/cache/cache.ts +225 -51
  6. package/src/cache/file-hashes.ts +3 -0
  7. package/src/cache/fold-memo.ts +8 -1
  8. package/src/cache/git-inputs.ts +188 -39
  9. package/src/cache/index.ts +12 -0
  10. package/src/cache/inputs.ts +3 -0
  11. package/src/cache/layer.ts +11 -0
  12. package/src/cache/layered-cache.ts +4 -0
  13. package/src/cache/linked-inputs.ts +3 -0
  14. package/src/cache/lockfile-memo.ts +120 -0
  15. package/src/cache/output-index.ts +5 -2
  16. package/src/cache/remote-hits.ts +9 -1
  17. package/src/cache/remote-lane.ts +2 -1
  18. package/src/cache/run-history.ts +11 -1
  19. package/src/cache/run-notes.ts +123 -0
  20. package/src/cache/schema.ts +54 -1
  21. package/src/cli/cache.ts +26 -16
  22. package/src/cli/completions.ts +4 -2
  23. package/src/cli/docs.ts +10 -11
  24. package/src/cli/help.ts +12 -23
  25. package/src/cli/index.ts +4 -9
  26. package/src/cli/info.ts +15 -7
  27. package/src/cli/init.ts +23 -15
  28. package/src/cli/last.ts +224 -223
  29. package/src/cli/lock.ts +55 -5
  30. package/src/cli/plan-format.ts +100 -118
  31. package/src/cli/plugin-commands.ts +10 -3
  32. package/src/cli/plugin-templates.ts +2 -2
  33. package/src/cli/run.ts +46 -27
  34. package/src/cli/select.ts +43 -5
  35. package/src/cli/show.ts +38 -42
  36. package/src/cli/task-verb.ts +22 -0
  37. package/src/cli/upgrade.ts +3 -1
  38. package/src/cli/watch-fs.ts +6 -1
  39. package/src/cli/watch-judge.ts +2 -1
  40. package/src/cli/watch.ts +83 -44
  41. package/src/cli/why.ts +126 -92
  42. package/src/cli/workspace-config.ts +2 -1
  43. package/src/exec/executor.ts +2 -1
  44. package/src/exec/kill-tree.ts +58 -5
  45. package/src/exec/local-executor.ts +1 -1
  46. package/src/exec/pipes.ts +152 -0
  47. package/src/exec/runner.ts +199 -182
  48. package/src/exec/sandbox-binds.ts +1 -1
  49. package/src/exec/sandbox-runtime.ts +11 -2
  50. package/src/exec/spawn-off.ts +154 -0
  51. package/src/exec/warm-shell.ts +146 -0
  52. package/src/graph/critical-path.ts +40 -28
  53. package/src/graph/priorities.ts +98 -44
  54. package/src/graph/scheduler.ts +12 -2
  55. package/src/graph/task-graph.ts +27 -1
  56. package/src/handoff.ts +10 -7
  57. package/src/history/history.ts +39 -2
  58. package/src/history/index.ts +3 -0
  59. package/src/history/metrics.ts +9 -1
  60. package/src/index.ts +12 -3
  61. package/src/orchestrator/doctor.ts +3 -3
  62. package/src/orchestrator/execute-task.ts +16 -2
  63. package/src/orchestrator/graph-memo.ts +50 -1
  64. package/src/orchestrator/graph-stage.ts +6 -0
  65. package/src/orchestrator/hit-restore.ts +3 -0
  66. package/src/orchestrator/index.ts +3 -1
  67. package/src/orchestrator/live-usage.ts +98 -0
  68. package/src/orchestrator/locked-digests.ts +63 -0
  69. package/src/orchestrator/lockfile-claim.ts +176 -102
  70. package/src/orchestrator/miss-save.ts +31 -3
  71. package/src/orchestrator/persistent-task.ts +3 -1
  72. package/src/orchestrator/plan.ts +5 -1
  73. package/src/orchestrator/plugin-host.ts +61 -0
  74. package/src/orchestrator/plugin.ts +49 -4
  75. package/src/orchestrator/prepare.ts +18 -2
  76. package/src/orchestrator/projects.ts +4 -1
  77. package/src/orchestrator/run-context.ts +29 -10
  78. package/src/orchestrator/run-failures.ts +35 -114
  79. package/src/orchestrator/run-records.ts +10 -0
  80. package/src/orchestrator/run.ts +89 -26
  81. package/src/orchestrator/sandbox-request.ts +2 -2
  82. package/src/orchestrator/signals.ts +8 -5
  83. package/src/orchestrator/steps.ts +2 -2
  84. package/src/orchestrator/task-hash.ts +17 -3
  85. package/src/report/forecast.ts +12 -33
  86. package/src/report/framed-output.ts +388 -161
  87. package/src/report/index.ts +8 -2
  88. package/src/report/logger.ts +100 -36
  89. package/src/report/status-line.ts +81 -365
  90. package/src/report/summary.ts +104 -131
  91. package/src/ui/ansi.ts +48 -0
  92. package/src/ui/components.ts +506 -0
  93. package/src/ui/index.ts +79 -0
  94. package/src/ui/kit.ts +37 -0
  95. package/src/ui/live-region.ts +363 -0
  96. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  97. package/src/ui/terminal.ts +51 -0
  98. package/src/ui/theme.ts +224 -0
  99. package/src/ui/version-line.ts +28 -0
  100. package/src/util/config-frame.ts +65 -10
  101. package/src/util/edit-distance.ts +6 -1
  102. package/src/util/hidden-prompt.ts +8 -2
  103. package/src/util/index.ts +2 -15
  104. package/src/util/paths.ts +20 -2
  105. package/src/util/secret-mask.ts +39 -3
  106. package/src/util/secrets.ts +3 -0
  107. package/src/util/settle.ts +31 -0
  108. package/src/util/timing.ts +4 -16
  109. package/src/workspace/affected.ts +89 -30
  110. package/src/workspace/config-schema.ts +4 -2
  111. package/src/workspace/dependencies-lock.ts +191 -0
  112. package/src/workspace/filter.ts +28 -0
  113. package/src/workspace/index.ts +9 -0
  114. package/src/workspace/lockfile.ts +159 -6
  115. package/src/workspace/migration.ts +4 -3
  116. package/src/workspace/workspace.ts +3 -0
  117. package/src/orchestrator/run-steps.ts +0 -93
  118. package/src/util/colors.ts +0 -130
  119. package/src/util/report.ts +0 -44
  120. package/src/util/ui.ts +0 -258
  121. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  122. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -8,11 +8,13 @@ import { EMPTY_LOCK, inputStabilityEnv, LOCK_INVALID, lockEntryBroken } from '..
8
8
  import { realpathSync } from 'node:fs'
9
9
  import path from 'node:path'
10
10
  import {
11
+ indexDb,
11
12
  Cache,
12
13
  type CachePolicy,
13
14
  cachesNothing,
14
15
  formatCachePolicy,
15
16
  networkConcurrency,
17
+ keepRunNotes,
16
18
  stopRuntimeProbes,
17
19
  sweepStaging,
18
20
  } from '../cache/index.js'
@@ -37,11 +39,12 @@ import {
37
39
  type TaskNode,
38
40
  type TaskOutcome,
39
41
  } from '../graph/index.js'
42
+ import { graphCounts } from './graph-memo.js'
40
43
  import {
41
44
  mark,
42
45
  stageTimes,
43
46
  MAX_TIMEOUT_MS,
44
- printTimings,
47
+ timingLines,
45
48
  ulid,
46
49
  isLiteralPattern,
47
50
  listed,
@@ -51,12 +54,17 @@ import {
51
54
  secretMask,
52
55
  isFsRefusal,
53
56
  isUserError,
54
- detectColors,
55
- type ColorSupport,
56
57
  SecretBroker,
57
58
  thrownMessage,
58
- type ui,
59
59
  } from '../util/index.js'
60
+ import * as ui from '../ui/index.js'
61
+
62
+ /** The stage table `VX_DEBUG_TIMING=1` asks for, on stderr. */
63
+ function printTimings(): void {
64
+ const lines = timingLines()
65
+ if (lines !== undefined) ui.print('err', ui.trace(lines))
66
+ }
67
+ import { detectColors, type ColorSupport } from '../ui/index.js'
60
68
  import { keyedProjects } from './keyed-projects.js'
61
69
  import { isDefaultBuild } from '../graph/index.js'
62
70
  import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
@@ -66,6 +74,7 @@ import { settleProbedHit, unkeyedGroupOutcome, type ExecuteArgs } from './execut
66
74
  import { executeArgsOf, type RunScope } from './execute-args.js'
67
75
  import { excludedTaint } from './excluded-keys.js'
68
76
  import { dependedOn, keyUpstream } from './upstream.js'
77
+ import { createLiveUsage } from './live-usage.js'
69
78
  import {
70
79
  busLogger,
71
80
  createEventBus,
@@ -93,7 +102,6 @@ import {
93
102
  } from './plugin-host.js'
94
103
  import { createSteps } from './steps.js'
95
104
  import { liveSocketOf, serveLive } from './run-live.js'
96
- import { writeRunSteps, writeRunWarnings } from './run-steps.js'
97
105
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
98
106
  import { claimOutput, guardedOutput } from './output-host.js'
99
107
  import { assembleRunSummary } from './telemetry.js'
@@ -353,6 +361,11 @@ export async function run(options: RunOptions): Promise<RunSummary> {
353
361
  const detach = (): void => {
354
362
  unsubscribeTerminal()
355
363
  unsubscribeWarned()
364
+ // A run refused or thrown before its tasks (an unknown task, a plugin
365
+ // stage that threw) never reached the end that takes the live region
366
+ // down: a load slow enough to show its step left `vx:load running`
367
+ // on screen, and the CLI's refusal was written onto its last line.
368
+ terminal?.runDone?.()
356
369
  terminal?.settle()
357
370
  }
358
371
  // A plugin's `output` renders the run from its start; core's renderer
@@ -910,7 +923,40 @@ async function runOnBus(
910
923
  const fault = executorFaults(warnOf(log))
911
924
  const footers = executors.filter((e) => e.footer !== undefined)
912
925
  const placers = executors.filter((e) => e.running !== undefined)
926
+ // The scheduler's dependents count, and every row's `blocks` (the live
927
+ // list's and the results'): one closure over the graph, built on the
928
+ // first that asks.
929
+ let counted: ReadonlyMap<string, number> | undefined
930
+ const blocksNow = (): ReadonlyMap<string, number> =>
931
+ (counted ??= graphCounts(nodes, hashCache.folds, hashCache.graph))
932
+ // Every row's `usual` and the forecast's timings: history read once, on
933
+ // the first row that asks, so a run that executes nothing never reads
934
+ // it. It was read only a second into a live run, so rows before that,
935
+ // a short run's and every log's showed none (2026-10-29).
936
+ let p50s: ReadonlyMap<string, number> | undefined
937
+ const p50sNow = (): ReadonlyMap<string, number> => {
938
+ if (p50s !== undefined) return p50s
939
+ const ids: string[] = []
940
+ for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
941
+ try {
942
+ p50s = new LocalHistoryProvider(
943
+ indexDb(prepared.localCache),
944
+ undefined,
945
+ prepared.branch(),
946
+ taskDefinitions(nodes.values()),
947
+ ).p50sNow(ids)
948
+ } catch {
949
+ // History is observability: an unreadable one knows no task.
950
+ p50s = new Map()
951
+ }
952
+ return p50s
953
+ }
954
+ // The live rows' `cpu` and `mem`; it looks only once the live list asks.
955
+ const liveUsage = createLiveUsage()
913
956
  const runContext = {
957
+ blocks: blocksNow,
958
+ usual: (id: string) => p50sNow().get(id),
959
+ usage: liveUsage.usage,
914
960
  ...(footers.length > 0
915
961
  ? { executorRows: () => footers.flatMap((e) => executorFooter(e, fault)) }
916
962
  : {}),
@@ -967,18 +1013,8 @@ async function runOnBus(
967
1013
  // history's only if the region is still up a second in, so a warm run
968
1014
  // never reads history.
969
1015
  terminal?.forecast(
970
- () => createForecast(nodes, new Map(), concurrency),
971
- async () => {
972
- const ids: string[] = []
973
- for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
974
- const p50s = await new LocalHistoryProvider(
975
- prepared.localCache.dbHandle(),
976
- undefined,
977
- prepared.branch(),
978
- taskDefinitions(nodes.values()),
979
- ).p50sFor(ids)
980
- return createForecast(nodes, p50s, concurrency)
981
- },
1016
+ () => createForecast(nodes, new Map(), concurrency, blocksNow()),
1017
+ async () => createForecast(nodes, p50sNow(), concurrency, blocksNow()),
982
1018
  )
983
1019
 
984
1020
  // Remote-only: kick off background prefetches so remote-GET latency
@@ -1112,7 +1148,7 @@ async function runOnBus(
1112
1148
  // Why a task missed, for a sink: a read of the history per miss, so only
1113
1149
  // when a sink exists.
1114
1150
  const explainMiss =
1115
- telemetry === undefined ? undefined : createMissExplainer(prepared.localCache.dbHandle())
1151
+ telemetry === undefined ? undefined : createMissExplainer(indexDb(prepared.localCache))
1116
1152
  const lateProbes = probesAfterWrites(nodes, workspaceRoot)
1117
1153
  // The scheduler's fail-fast stop, as the retry loop of a task already
1118
1154
  // in flight sees it.
@@ -1143,7 +1179,15 @@ async function runOnBus(
1143
1179
  if (options.retries !== undefined) scope.retries = options.retries
1144
1180
  if (taskTimeoutDefault !== undefined) scope.timeout = taskTimeoutDefault
1145
1181
  if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
1146
- if (telemetry?.track !== undefined) scope.track = telemetry.track
1182
+ const sinkTrack = telemetry?.track
1183
+ scope.track = (taskId, pid) => {
1184
+ const untrack = liveUsage.track(taskId, pid)
1185
+ const sunk = sinkTrack?.(taskId, pid)
1186
+ return () => {
1187
+ untrack()
1188
+ return sunk?.()
1189
+ }
1190
+ }
1147
1191
  if (explainMiss !== undefined) scope.explainMiss = explainMiss
1148
1192
  if (keepRun !== undefined) scope.keepRun = keepRun
1149
1193
  if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
@@ -1215,7 +1259,7 @@ async function runOnBus(
1215
1259
  // rows land, so the row that prints next carries the verdict: nothing
1216
1260
  // prints below the footer. A hit or a skip asks nothing; a green miss
1217
1261
  // is one probe of the failed-row index.
1218
- const historyDb = prepared.localCache.dbHandle()
1262
+ const historyDb = indexDb(prepared.localCache)
1219
1263
  const cacheOff = cachesNothing(policy)
1220
1264
  const judgeFlaky = (o: TaskOutcome): void => {
1221
1265
  const candidates = flakyCandidates([o], taintedRan)
@@ -1249,10 +1293,12 @@ async function runOnBus(
1249
1293
  }
1250
1294
  }
1251
1295
  let lockBroken: boolean | undefined
1296
+ prepared.localCache.storeHeldBlobSizes()
1252
1297
  const outcomes = await runGraph({
1253
1298
  nodes,
1254
1299
  concurrency,
1255
1300
  restoreConcurrency: options.restoreConcurrency ?? 2 * concurrency,
1301
+ reverseDeps: blocksNow,
1256
1302
  settledOf: (o) => deferredSaves.get(o.node.id),
1257
1303
  ...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
1258
1304
  ...(admit !== undefined ? { admit } : {}),
@@ -1361,6 +1407,9 @@ async function runOnBus(
1361
1407
  const hold =
1362
1408
  options.holdPersistent === true &&
1363
1409
  (!stop.signal.aborted || stop.signal.reason === CYCLE_INTERRUPT)
1410
+ // A stop already signalled every server once and waits out its grace:
1411
+ // a SIGTERM here, while a server ran its cleanup, cut it short.
1412
+ if (stop.signal.aborted) await stop.aborting?.catch(() => {})
1364
1413
  const { keepAlive, crashed: crashedPersistent } = await disposeServers({
1365
1414
  registry: persistentRegistry,
1366
1415
  nodes,
@@ -1469,6 +1518,7 @@ async function runOnBus(
1469
1518
  ci: ciContext,
1470
1519
  host: hostContext,
1471
1520
  withTelemetry: telemetry !== undefined,
1521
+ blocks: blocksNow(),
1472
1522
  remoteTraffic: prepared.localCache.traffic.keys,
1473
1523
  })
1474
1524
  const records = recordsOf(list, ok)
@@ -1492,8 +1542,11 @@ async function runOnBus(
1492
1542
  // A cache directory this user cannot write keeps none of the run's
1493
1543
  // records: the run's one notice said so.
1494
1544
  const keeps = prepared.localCache.readOnlyNotice() === null
1495
- const recordFailures = (final: readonly TaskOutcome[]): void => {
1496
- if (keeps) writeRunFailures(prepared.cacheDir, runId, final, warnOf(log))
1545
+ const recordFailures = (
1546
+ final: readonly TaskOutcome[],
1547
+ into: Cache = prepared.localCache,
1548
+ ): void => {
1549
+ if (keeps) writeRunFailures(into, runId, final, warnOf(log))
1497
1550
  }
1498
1551
  if (stop.stoppedBy === undefined && !historyAfterWait) {
1499
1552
  stepSync('vx:history', () => {
@@ -1535,8 +1588,18 @@ async function runOnBus(
1535
1588
  await Promise.all([cache.drainUploads?.(), keeper?.settled()])
1536
1589
  })
1537
1590
  if (keeps) {
1538
- writeRunSteps(prepared.cacheDir, runId, steps.ended, warnOf(log))
1539
- writeRunWarnings(prepared.cacheDir, runId, warned, warnOf(log))
1591
+ // Beside the run's rows (cache/run-notes.ts); a refused write costs only them.
1592
+ try {
1593
+ keepRunNotes(indexDb(prepared.localCache), runId, {
1594
+ steps: steps.ended,
1595
+ warnings: warned.map((w) => Bun.stripANSI(w)),
1596
+ })
1597
+ } catch (err) {
1598
+ const message = err instanceof Error ? err.message : String(err)
1599
+ warnOf(log)(
1600
+ `[vx] background steps and warnings not kept: ${message} — the verdict above stands`,
1601
+ )
1602
+ }
1540
1603
  }
1541
1604
  // An upload that failed after the rows went in: its row says so too.
1542
1605
  if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
@@ -1670,11 +1733,11 @@ async function runOnBus(
1670
1733
  const local = new Cache(prepared.cacheDir, undefined, prepared.workspaceRoot)
1671
1734
  try {
1672
1735
  local.recordRunBundle(recordsOf(final, ok && !crashed))
1736
+ recordFailures(final, local)
1673
1737
  } finally {
1674
1738
  local.close()
1675
1739
  }
1676
1740
  })
1677
- recordFailures(final)
1678
1741
  }
1679
1742
  return { ok: ok && !crashed, outcomes: final }
1680
1743
  }
@@ -1789,7 +1852,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1789
1852
  // inspection command, so the history read's cost is fine here even
1790
1853
  // though it stays OFF the default run path.
1791
1854
  history: new LocalHistoryProvider(
1792
- prepared.localCache.dbHandle(),
1855
+ indexDb(prepared.localCache),
1793
1856
  undefined,
1794
1857
  prepared.branch(),
1795
1858
  taskDefinitions(prepared.nodes.values()),
@@ -446,8 +446,8 @@ export function withheldLinkLine(taskId: string, w: WithheldLink): string {
446
446
  return (
447
447
  `vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key includes no task of ` +
448
448
  `${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
449
- `reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
450
- `\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
449
+ `reaches one (\`^build\`, where ${w.name}#build keys its sources), or grant and key ` +
450
+ `the files yourself (\`allow.read\` plus ` +
451
451
  `\`inputs.workspaceFiles\`).`
452
452
  )
453
453
  }
@@ -14,7 +14,13 @@
14
14
  // them to the runner around every spawn.
15
15
 
16
16
  import { signalExitCode } from '../exec/index.js'
17
- import { claimExitForSignal, hangupIgnored, killGraceMs, settleWithin } from '../util/index.js'
17
+ import {
18
+ claimExitForSignal,
19
+ dieOf,
20
+ hangupIgnored,
21
+ killGraceMs,
22
+ settleWithin,
23
+ } from '../util/index.js'
18
24
  import {
19
25
  holdGroups,
20
26
  killTree,
@@ -154,10 +160,7 @@ export function forwardSignals(args: {
154
160
  // and a 130 exit ran the script's next line. The code stays the
155
161
  // fallback should the signal not land.
156
162
  if (args.handsTerminal !== true) {
157
- process.once('exit', () => {
158
- process.removeAllListeners(signal)
159
- process.kill(process.pid, signal)
160
- })
163
+ process.once('exit', () => dieOf(signal))
161
164
  }
162
165
  process.exit(signalExitCode(signal))
163
166
  }
@@ -6,9 +6,9 @@
6
6
  //
7
7
  // A run shows only the steps it waits on, before its first task or its end
8
8
  // (2026-10-16). One that runs beside the tasks (`vx:prefetch`) is a
9
- // `background` step: never shown, and kept for `vx last` (run-steps.ts).
9
+ // `background` step: never shown, and kept for `vx last` (cache/run-notes.ts).
10
10
  import type { Logger } from '../report/index.js'
11
- import type { RunStep } from './run-steps.js'
11
+ import type { RunStep } from '../cache/index.js'
12
12
 
13
13
  export interface Steps {
14
14
  /** Time `work` as step `id` when steps show; else run it as it is. */
@@ -519,9 +519,23 @@ function hashableConfig(cfg: TaskConfig): unknown {
519
519
  // Keys sorted: moving `cache` above `exec`, or a `define` in another
520
520
  // order, missed every key of the task, and a `project` or `graph` plugin
521
521
  // writes a config the lock never sorted (2026-10-25).
522
- if (cfg.exec?.remote === undefined) return keysSorted(cfg)
523
- const { remote: _remote, ...execRest } = cfg.exec
524
- return keysSorted({ ...cfg, exec: execRest })
522
+ const env = cfg.inputs?.env
523
+ const pass = cfg.exec?.env?.passThrough
524
+ if (cfg.exec?.remote === undefined && sortedNames(env) && sortedNames(pass))
525
+ return keysSorted(cfg)
526
+ const { remote: _remote, ...execRest } = cfg.exec ?? ({} as NonNullable<TaskConfig['exec']>)
527
+ // Env names are sets: each value is read by its name, so their order
528
+ // keys nothing (conformance env/declared-order-irrelevant).
529
+ if (!sortedNames(pass)) execRest.env = { ...execRest.env, passThrough: [...pass!].sort() }
530
+ const out: TaskConfig = cfg.exec === undefined ? { ...cfg } : { ...cfg, exec: execRest }
531
+ if (!sortedNames(env)) out.inputs = { ...cfg.inputs!, env: [...env!].sort() }
532
+ return keysSorted(out)
533
+ }
534
+
535
+ function sortedNames(names: readonly string[] | undefined): boolean {
536
+ if (names === undefined) return true
537
+ for (let i = 1; i < names.length; i++) if (names[i - 1]! > names[i]!) return false
538
+ return true
525
539
  }
526
540
 
527
541
  /**
@@ -12,8 +12,6 @@ export interface LiveForecast {
12
12
  critical?: string
13
13
  /** Tasks not started because a dependency has not finished: the tasks line's `blocked`. */
14
14
  waiting: number
15
- /** Each running task's p50, where history knows it: the row's expected duration. */
16
- expected?: ReadonlyMap<string, number>
17
15
  /**
18
16
  * Tasks not started because a dependency has not finished, in the order
19
17
  * the scheduler would start them, at most one per worker: what an idle
@@ -21,9 +19,9 @@ export interface LiveForecast {
21
19
  */
22
20
  blocked?: readonly BlockedTask[]
23
21
  /**
24
- * Each running task's count of the tasks not started that depend on it
25
- * directly (through a group, which runs nothing): its row's `blocked`
26
- * cell, why they are not running. A task waiting on two running tasks
22
+ * Each running task's count of what is downstream of it, the
23
+ * scheduler's own count (`computeReverseDepCount`): its row's `blocks`
24
+ * cell, why they are not running. A node downstream of two running tasks
27
25
  * counts under both.
28
26
  */
29
27
  blockedBy: ReadonlyMap<string, number>
@@ -53,6 +51,8 @@ export function createForecast(
53
51
  nodes: ReadonlyMap<string, TaskNode>,
54
52
  p50s: ReadonlyMap<string, number>,
55
53
  concurrency: number,
54
+ /** Every node's dependents count, the scheduler's baseline and the rows' `blocks`; computed here when absent. */
55
+ rank: ReadonlyMap<string, number> = computeReverseDepCount(nodes),
56
56
  ): Forecast {
57
57
  // Dependencies before dependents, once: each call is one pass over it.
58
58
  const order: TaskNode[] = []
@@ -84,38 +84,16 @@ export function createForecast(
84
84
  if (p !== undefined) cost.set(n.id, p)
85
85
  }
86
86
  const workers = Math.max(1, concurrency)
87
- // The scheduler's own baseline ranking, most dependents first, ties in
88
- // graph order: once per run, as the scheduler computes it.
89
- const rank = computeReverseDepCount(nodes)
87
+ // The scheduler's own baseline ranking (`rank`), most dependents first,
88
+ // ties in graph order.
90
89
  const position = new Map(order.map((n, i) => [n.id, i]))
91
- // The tasks each node waits on directly, a group's own looked through.
92
- const direct = new Map<string, readonly string[]>()
93
- const through = (id: string, into: Set<string>, seen: Set<string>): void => {
94
- if (seen.has(id)) return
95
- seen.add(id)
96
- const d = nodes.get(id)
97
- if (d === undefined || !isGroupTask(d)) into.add(id)
98
- else for (const x of d.deps) through(x, into, seen)
99
- }
100
- for (const n of order) {
101
- if (isGroupTask(n) || n.deps.length === 0) continue
102
- const into = new Set<string>()
103
- const seen = new Set<string>()
104
- for (const d of n.deps) through(d, into, seen)
105
- direct.set(n.id, [...into])
106
- }
107
90
  const before = (a: string, b: string): boolean => {
108
91
  const d = (rank.get(a) ?? 0) - (rank.get(b) ?? 0)
109
92
  return d !== 0 ? d > 0 : position.get(a)! < position.get(b)!
110
93
  }
111
94
 
112
95
  return (finished, running, nowMs) => {
113
- const expected = new Map<string, number>()
114
96
  let waiting = 0
115
- for (const id of running.keys()) {
116
- const p = cost.get(id)
117
- if (p !== undefined) expected.set(id, p)
118
- }
119
97
  const end = new Map<string, number>()
120
98
  // The running task each node's longest chain waits on.
121
99
  const head = new Map<string, string>()
@@ -144,8 +122,6 @@ export function createForecast(
144
122
  since === undefined ? (head.get(n.id) ?? n.deps.find((d) => end.has(d))) : undefined
145
123
  if (by !== undefined && !isGroupTask(n)) {
146
124
  waiting++
147
- for (const on of direct.get(n.id) ?? [])
148
- if (running.has(on)) blockedBy.set(on, (blockedBy.get(on) ?? 0) + 1)
149
125
  let at = held.length
150
126
  while (at > 0 && before(n.id, held[at - 1]!.id)) at--
151
127
  if (at < workers) {
@@ -167,13 +143,16 @@ export function createForecast(
167
143
  }
168
144
  work += own
169
145
  }
170
- if (!known) return { waiting, expected, blocked: held, blockedBy }
146
+ for (const id of running.keys()) {
147
+ const d = rank.get(id)
148
+ if (d !== undefined && d > 0) blockedBy.set(id, d)
149
+ }
150
+ if (!known) return { waiting, blocked: held, blockedBy }
171
151
  const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
172
152
  return {
173
153
  etaMs: Math.max(chain, work / workers),
174
154
  ...(critical !== undefined ? { critical } : {}),
175
155
  waiting,
176
- expected,
177
156
  blocked: held,
178
157
  blockedBy,
179
158
  }