@vzn/vx 0.0.649 → 0.0.650

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.
@@ -1,20 +1,15 @@
1
1
  // A failed run's task output and the files it names, so an agent reads why
2
- // a task failed without the terminal (`vx last --format json`). One file per failed run, `<cacheDir>/failures/<runId>.json`,
3
- // the newest 50 kept: files, not cache.db, so the index layout (and every
4
- // shared store under it) stays as it was.
2
+ // a task failed without the terminal (`vx last --format json`). Rows of
3
+ // cache.db's `failures` (cache/run-notes.ts), the newest 50 failed runs kept.
5
4
 
6
5
  import type { Database } from 'bun:sqlite'
7
- import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
8
- import path from 'node:path'
6
+ import { indexDb, type Cache, keepRunNotes, keptFailures } from '../cache/index.js'
9
7
  import { isGroupTask, type TaskOutcome } from '../graph/index.js'
10
8
  import { decodeOutputLog } from './output-log.js'
11
9
  import { PASS_STATUSES } from '../history/index.js'
12
- import { isFsRefusal, maskedLine, splitTaskId } from '../util/index.js'
10
+ import { maskedLine, splitTaskId } from '../util/index.js'
13
11
  import { fileLocations, fileMemo, type OutputLocation } from '../report/index.js'
14
12
 
15
- /** Failed runs whose output is kept; run ids are UUIDv7, so names sort by age. */
16
- const KEPT = 50
17
-
18
13
  /** A failed task of a run and what it said. */
19
14
  export interface TaskFailure {
20
15
  taskId: string
@@ -36,17 +31,19 @@ export interface RunFailures {
36
31
  tasks: TaskFailure[]
37
32
  }
38
33
 
39
- interface StoredFailure extends TaskFailure {
40
- project: string
41
- task: string
42
- }
43
-
44
- const dirOf = (cacheDir: string): string => path.join(cacheDir, 'failures')
45
-
46
- /** Each failed outcome's output as plain text and the files it names. */
47
- function failuresOf(outcomes: readonly TaskOutcome[]): StoredFailure[] {
34
+ /**
35
+ * Keep the run's failures, when any outcome carries output. A refused write
36
+ * (a full disk) costs only this output: `warn` says so and the run's
37
+ * verdict stands.
38
+ */
39
+ export function writeRunFailures(
40
+ cache: Cache,
41
+ runId: string,
42
+ outcomes: readonly TaskOutcome[],
43
+ warn: (message: string) => void,
44
+ ): void {
48
45
  const isFile = fileMemo()
49
- const out: StoredFailure[] = []
46
+ const failures = []
50
47
  for (const o of outcomes) {
51
48
  // A task failed by a moved input may have printed nothing: the input
52
49
  // is still why, and an agent reads it here.
@@ -60,76 +57,30 @@ function failuresOf(outcomes: readonly TaskOutcome[]): StoredFailure[] {
60
57
  .map((c) => c.text)
61
58
  .join(''),
62
59
  )
63
- out.push({
60
+ failures.push({
64
61
  taskId: o.node.id,
65
- project: o.node.projectName,
66
- task: o.node.taskName,
67
- exitCode: o.exitCode,
68
- ...(o.timedOut === true ? { timedOut: true as const } : {}),
69
62
  ...(o.inputMoved !== undefined ? { inputMoved: o.inputMoved } : {}),
70
63
  output,
71
64
  locations: fileLocations(output, o.node.projectDir, isFile),
72
65
  ...(o.sandboxFix !== undefined ? { sandboxFix: o.sandboxFix } : {}),
73
66
  })
74
67
  }
75
- return out
76
- }
77
-
78
- /**
79
- * Write the run's failures, when any outcome carries output, and drop all
80
- * but the newest `KEPT` files. Atomic: a reader sees the whole file or none.
81
- * A refused write (a full disk, a read-only cache dir) costs only this
82
- * output: `warn` says so and the run's verdict stands.
83
- */
84
- export function writeRunFailures(
85
- cacheDir: string,
86
- runId: string,
87
- outcomes: readonly TaskOutcome[],
88
- warn: (message: string) => void,
89
- ): void {
90
- const tasks = failuresOf(outcomes)
91
- if (tasks.length === 0) return
68
+ if (failures.length === 0) return
92
69
  try {
93
- keep(cacheDir, runId, tasks)
70
+ keepRunNotes(indexDb(cache), runId, { failures })
94
71
  } catch (err) {
95
- if (!isFsRefusal(err)) throw err
96
- warn(`[vx] failed tasks' output not kept: ${err.message} — the verdict above stands`)
72
+ const message = err instanceof Error ? err.message : String(err)
73
+ warn(`[vx] failed tasks' output not kept: ${message} — the verdict above stands`)
97
74
  }
98
75
  }
99
76
 
100
- function keep(cacheDir: string, runId: string, tasks: TaskFailure[]): void {
101
- const dir = dirOf(cacheDir)
102
- mkdirSync(dir, { recursive: true })
103
- const file = path.join(dir, `${runId}.json`)
104
- const tmp = `${file}.${process.pid}.tmp`
105
- writeFileSync(tmp, JSON.stringify({ runId, tasks }))
106
- renameSync(tmp, file)
107
- trimKept(dir, KEPT)
108
- }
109
-
110
- /**
111
- * Keep the newest `kept` runs' files in `dir`, and the temp files of runs
112
- * no older: a writer that crashed between its write and its rename left
113
- * `<runId>.json.<pid>.tmp`, which the trim of `.json` names never took. A
114
- * live writer's temp is of a run newer than every kept one.
115
- */
116
- export function trimKept(dir: string, kept: number): void {
117
- const all = readdirSync(dir)
118
- const names = all.filter((n) => n.endsWith('.json')).sort()
119
- for (const n of names.slice(0, -kept)) rmSync(path.join(dir, n), { force: true })
120
- if (names.length < kept) return
121
- const oldest = names[names.length - kept]!
122
- for (const n of all)
123
- if (n.endsWith('.tmp') && n.slice(0, n.lastIndexOf('.json.') + 5) < oldest)
124
- rmSync(path.join(dir, n), { force: true })
125
- }
126
-
127
77
  /**
128
78
  * The failed tasks of `runId`, or of the latest failed run when omitted;
129
- * null when there is no such run. A failed task the file lacks (an older
130
- * run, a write the disk refused) reads `output: ''`, `locations: []`.
79
+ * null when there is no such run. A failed task with no kept row (a run
80
+ * past the newest 50, a write the disk refused) reads `output: ''`,
81
+ * `locations: []`.
131
82
  */
132
- export function runFailures(cacheDir: string, db: Database, runId?: string): RunFailures | null {
83
+ export function runFailures(db: Database, runId?: string): RunFailures | null {
133
84
  const id =
134
85
  runId ??
135
86
  (
@@ -138,7 +89,7 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
138
89
  .get() as { run_id: string } | null
139
90
  )?.run_id
140
91
  if (id === undefined) return null
141
- const kept = storedFailures(cacheDir, id)
92
+ const kept = keptFailures(db, id)
142
93
  const rows = db
143
94
  .query(
144
95
  `SELECT r.project, r.task, r.exit_code AS exitCode, r.timed_out AS timedOut,
@@ -165,41 +116,16 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
165
116
  ...(r.timedOut === 1 ? { timedOut: true as const } : {}),
166
117
  ...(k?.inputMoved !== undefined ? { inputMoved: k.inputMoved } : {}),
167
118
  output: k?.output ?? '',
168
- locations: k?.locations ?? [],
169
- ...(k?.sandboxFix !== undefined ? { sandboxFix: k.sandboxFix } : {}),
119
+ locations: (k?.locations ?? []) as OutputLocation[],
120
+ ...(k?.sandboxFix !== undefined
121
+ ? { sandboxFix: k.sandboxFix as NonNullable<TaskOutcome['sandboxFix']> }
122
+ : {}),
170
123
  ...(r.fixedIn !== null ? { fixedIn: r.fixedIn } : {}),
171
124
  }
172
125
  }),
173
126
  }
174
127
  }
175
128
 
176
- /** What `runId`'s failures file holds, by task id; empty when the run kept none. */
177
- function storedFailures(cacheDir: string, runId: string): Map<string, StoredFailure> {
178
- const kept = new Map<string, StoredFailure>()
179
- // A run id the caller typed names a file in failures/ only.
180
- if (path.basename(runId) !== runId || runId === '..') return kept
181
- let text: string
182
- try {
183
- text = readFileSync(path.join(dirOf(cacheDir), `${runId}.json`), 'utf8')
184
- } catch (err) {
185
- // No file: the run kept no output.
186
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
187
- return kept
188
- }
189
- // A file the disk left torn (a rename that landed before its data, on
190
- // power loss) is output the run did not keep: it threw on every `vx last`
191
- // until 50 newer runs rotated it out.
192
- let stored: unknown
193
- try {
194
- stored = JSON.parse(text)
195
- } catch {
196
- return kept
197
- }
198
- const tasks = (stored as { tasks?: unknown } | null)?.tasks
199
- if (Array.isArray(tasks)) for (const t of tasks as StoredFailure[]) kept.set(t.taskId, t)
200
- return kept
201
- }
202
-
203
129
  /** One task's output in a recorded run, as `vx last --log` and getTaskLog read it. */
204
130
  export interface TaskLog {
205
131
  runId: string
@@ -219,12 +145,7 @@ export interface TaskLog {
219
145
  * `taskId`'s output in `runId`, or in the latest run that recorded the
220
146
  * task when omitted; null when no such run recorded it.
221
147
  */
222
- export function taskLog(
223
- cacheDir: string,
224
- db: Database,
225
- taskId: string,
226
- runId?: string,
227
- ): TaskLog | null {
148
+ export function taskLog(db: Database, taskId: string, runId?: string): TaskLog | null {
228
149
  const [project, task] = splitTaskId(taskId)
229
150
  if (project === '' || task === '') return null
230
151
  const row = db
@@ -241,9 +162,9 @@ export function taskLog(
241
162
  if (row === null) return null
242
163
  const base = { runId: row.runId, taskId, status: row.status }
243
164
  if (row.status === 'failed') {
244
- // Only the file: runFailures reads `output: ''` for a task it lacks, as
245
- // if the task printed nothing, when the run is past the newest 50.
246
- const kept = storedFailures(cacheDir, row.runId).get(taskId)
165
+ // Only the kept row: runFailures reads `output: ''` for a task it lacks,
166
+ // as if the task printed nothing, when the run is past the newest 50.
167
+ const kept = keptFailures(db, row.runId).get(taskId)
247
168
  if (kept !== undefined) return { ...base, source: 'failure', output: kept.output }
248
169
  } else if (row.hash !== '') {
249
170
  const stored = db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(row.hash) as {
@@ -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'
@@ -66,7 +68,9 @@ import { settleProbedHit, unkeyedGroupOutcome, type ExecuteArgs } from './execut
66
68
  import { executeArgsOf, type RunScope } from './execute-args.js'
67
69
  import { excludedTaint } from './excluded-keys.js'
68
70
  import { dependedOn, keyUpstream } from './upstream.js'
71
+ import { createLiveUsage } from './live-usage.js'
69
72
  import {
73
+ blocksCounts,
70
74
  busLogger,
71
75
  createEventBus,
72
76
  createForecast,
@@ -93,7 +97,6 @@ import {
93
97
  } from './plugin-host.js'
94
98
  import { createSteps } from './steps.js'
95
99
  import { liveSocketOf, serveLive } from './run-live.js'
96
- import { writeRunSteps, writeRunWarnings } from './run-steps.js'
97
100
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
98
101
  import { claimOutput, guardedOutput } from './output-host.js'
99
102
  import { assembleRunSummary } from './telemetry.js'
@@ -910,7 +913,15 @@ async function runOnBus(
910
913
  const fault = executorFaults(warnOf(log))
911
914
  const footers = executors.filter((e) => e.footer !== undefined)
912
915
  const placers = executors.filter((e) => e.running !== undefined)
916
+ // Every row's `blocks`, the live list's and the results': one closure
917
+ // over the graph, built on the first row that asks.
918
+ let blocks: ReadonlyMap<string, number> | undefined
919
+ const blocksNow = (): ReadonlyMap<string, number> => (blocks ??= blocksCounts(nodes))
920
+ // The live rows' `cpu` and `mem`; it looks only once the live list asks.
921
+ const liveUsage = createLiveUsage()
913
922
  const runContext = {
923
+ blocks: blocksNow,
924
+ usage: liveUsage.usage,
914
925
  ...(footers.length > 0
915
926
  ? { executorRows: () => footers.flatMap((e) => executorFooter(e, fault)) }
916
927
  : {}),
@@ -967,17 +978,17 @@ async function runOnBus(
967
978
  // history's only if the region is still up a second in, so a warm run
968
979
  // never reads history.
969
980
  terminal?.forecast(
970
- () => createForecast(nodes, new Map(), concurrency),
981
+ () => createForecast(nodes, new Map(), concurrency, blocksNow()),
971
982
  async () => {
972
983
  const ids: string[] = []
973
984
  for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
974
985
  const p50s = await new LocalHistoryProvider(
975
- prepared.localCache.dbHandle(),
986
+ indexDb(prepared.localCache),
976
987
  undefined,
977
988
  prepared.branch(),
978
989
  taskDefinitions(nodes.values()),
979
990
  ).p50sFor(ids)
980
- return createForecast(nodes, p50s, concurrency)
991
+ return createForecast(nodes, p50s, concurrency, blocksNow())
981
992
  },
982
993
  )
983
994
 
@@ -1112,7 +1123,7 @@ async function runOnBus(
1112
1123
  // Why a task missed, for a sink: a read of the history per miss, so only
1113
1124
  // when a sink exists.
1114
1125
  const explainMiss =
1115
- telemetry === undefined ? undefined : createMissExplainer(prepared.localCache.dbHandle())
1126
+ telemetry === undefined ? undefined : createMissExplainer(indexDb(prepared.localCache))
1116
1127
  const lateProbes = probesAfterWrites(nodes, workspaceRoot)
1117
1128
  // The scheduler's fail-fast stop, as the retry loop of a task already
1118
1129
  // in flight sees it.
@@ -1143,7 +1154,15 @@ async function runOnBus(
1143
1154
  if (options.retries !== undefined) scope.retries = options.retries
1144
1155
  if (taskTimeoutDefault !== undefined) scope.timeout = taskTimeoutDefault
1145
1156
  if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
1146
- if (telemetry?.track !== undefined) scope.track = telemetry.track
1157
+ const sinkTrack = telemetry?.track
1158
+ scope.track = (taskId, pid) => {
1159
+ const untrack = liveUsage.track(taskId, pid)
1160
+ const sunk = sinkTrack?.(taskId, pid)
1161
+ return () => {
1162
+ untrack()
1163
+ return sunk?.()
1164
+ }
1165
+ }
1147
1166
  if (explainMiss !== undefined) scope.explainMiss = explainMiss
1148
1167
  if (keepRun !== undefined) scope.keepRun = keepRun
1149
1168
  if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
@@ -1215,7 +1234,7 @@ async function runOnBus(
1215
1234
  // rows land, so the row that prints next carries the verdict: nothing
1216
1235
  // prints below the footer. A hit or a skip asks nothing; a green miss
1217
1236
  // is one probe of the failed-row index.
1218
- const historyDb = prepared.localCache.dbHandle()
1237
+ const historyDb = indexDb(prepared.localCache)
1219
1238
  const cacheOff = cachesNothing(policy)
1220
1239
  const judgeFlaky = (o: TaskOutcome): void => {
1221
1240
  const candidates = flakyCandidates([o], taintedRan)
@@ -1261,8 +1280,8 @@ async function runOnBus(
1261
1280
  serverDied,
1262
1281
  onFailFast: () => failFast.abort(),
1263
1282
  signal: stop.signal,
1264
- onStart: (node) => {
1265
- log.taskStart?.(node)
1283
+ onStart: (node, queuedMs) => {
1284
+ log.taskStart?.(node, queuedMs === undefined ? undefined : { waitedMs: queuedMs })
1266
1285
  },
1267
1286
  onFinish: (o) => {
1268
1287
  if (cacheOff) o.cacheOff = true
@@ -1492,8 +1511,11 @@ async function runOnBus(
1492
1511
  // A cache directory this user cannot write keeps none of the run's
1493
1512
  // records: the run's one notice said so.
1494
1513
  const keeps = prepared.localCache.readOnlyNotice() === null
1495
- const recordFailures = (final: readonly TaskOutcome[]): void => {
1496
- if (keeps) writeRunFailures(prepared.cacheDir, runId, final, warnOf(log))
1514
+ const recordFailures = (
1515
+ final: readonly TaskOutcome[],
1516
+ into: Cache = prepared.localCache,
1517
+ ): void => {
1518
+ if (keeps) writeRunFailures(into, runId, final, warnOf(log))
1497
1519
  }
1498
1520
  if (stop.stoppedBy === undefined && !historyAfterWait) {
1499
1521
  stepSync('vx:history', () => {
@@ -1535,8 +1557,18 @@ async function runOnBus(
1535
1557
  await Promise.all([cache.drainUploads?.(), keeper?.settled()])
1536
1558
  })
1537
1559
  if (keeps) {
1538
- writeRunSteps(prepared.cacheDir, runId, steps.ended, warnOf(log))
1539
- writeRunWarnings(prepared.cacheDir, runId, warned, warnOf(log))
1560
+ // Beside the run's rows (cache/run-notes.ts); a refused write costs only them.
1561
+ try {
1562
+ keepRunNotes(indexDb(prepared.localCache), runId, {
1563
+ steps: steps.ended,
1564
+ warnings: warned.map((w) => Bun.stripANSI(w)),
1565
+ })
1566
+ } catch (err) {
1567
+ const message = err instanceof Error ? err.message : String(err)
1568
+ warnOf(log)(
1569
+ `[vx] background steps and warnings not kept: ${message} — the verdict above stands`,
1570
+ )
1571
+ }
1540
1572
  }
1541
1573
  // An upload that failed after the rows went in: its row says so too.
1542
1574
  if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
@@ -1670,11 +1702,11 @@ async function runOnBus(
1670
1702
  const local = new Cache(prepared.cacheDir, undefined, prepared.workspaceRoot)
1671
1703
  try {
1672
1704
  local.recordRunBundle(recordsOf(final, ok && !crashed))
1705
+ recordFailures(final, local)
1673
1706
  } finally {
1674
1707
  local.close()
1675
1708
  }
1676
1709
  })
1677
- recordFailures(final)
1678
1710
  }
1679
1711
  return { ok: ok && !crashed, outcomes: final }
1680
1712
  }
@@ -1789,7 +1821,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1789
1821
  // inspection command, so the history read's cost is fine here even
1790
1822
  // though it stays OFF the default run path.
1791
1823
  history: new LocalHistoryProvider(
1792
- prepared.localCache.dbHandle(),
1824
+ indexDb(prepared.localCache),
1793
1825
  undefined,
1794
1826
  prepared.branch(),
1795
1827
  taskDefinitions(prepared.nodes.values()),
@@ -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. */
@@ -45,7 +45,7 @@ export interface RunStartInfo {
45
45
  */
46
46
  export type RunEvent =
47
47
  | { kind: 'run:start'; info: RunStartInfo }
48
- | { kind: 'task:start'; node: TaskNode }
48
+ | { kind: 'task:start'; node: TaskNode; waitedMs?: number }
49
49
  | { kind: 'task:stdout'; node: TaskNode; chunk: string }
50
50
  | { kind: 'task:stderr'; node: TaskNode; chunk: string }
51
51
  | { kind: 'task:complete'; node: TaskNode; outcome: TaskOutcome }
@@ -139,7 +139,12 @@ export function busLogger(bus: EventBus): Logger {
139
139
  taskStderr: (node, chunk) => bus.emit({ kind: 'task:stderr', node, chunk }),
140
140
  taskComplete: (node, outcome) => bus.emit({ kind: 'task:complete', node, outcome }),
141
141
  runStart: (info) => bus.emit({ kind: 'run:start', info }),
142
- taskStart: (node) => bus.emit({ kind: 'task:start', node }),
142
+ taskStart: (node, info) =>
143
+ bus.emit(
144
+ info?.waitedMs === undefined
145
+ ? { kind: 'task:start', node }
146
+ : { kind: 'task:start', node, waitedMs: info.waitedMs },
147
+ ),
143
148
  runEnd: () => {
144
149
  if (ended) return
145
150
  ended = true
@@ -174,7 +179,10 @@ export function terminalSubscriber(sink: Logger): RunEventSubscriber {
174
179
  sink.runStart?.(event.info)
175
180
  return
176
181
  case 'task:start':
177
- sink.taskStart?.(event.node)
182
+ sink.taskStart?.(
183
+ event.node,
184
+ event.waitedMs === undefined ? undefined : { waitedMs: event.waitedMs },
185
+ )
178
186
  return
179
187
  case 'task:stdout':
180
188
  sink.taskStdout(event.node, event.chunk)
@@ -21,12 +21,13 @@ export interface LiveForecast {
21
21
  */
22
22
  blocked?: readonly BlockedTask[]
23
23
  /**
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
27
- * counts under both.
24
+ * Each running task's count of the tasks downstream of it (a group,
25
+ * which runs nothing, looked through): its row's `blocks` cell, why they
26
+ * are not running. A task downstream of two running tasks counts under both.
28
27
  */
29
28
  blockedBy: ReadonlyMap<string, number>
29
+ /** Each task's usual time (its executed p50), for the results' `±usual`. */
30
+ usual?: ReadonlyMap<string, number>
30
31
  }
31
32
 
32
33
  /** A task an unfinished dependency holds back, and what holds it: the running task its longest chain waits on, else that dependency. */
@@ -53,6 +54,8 @@ export function createForecast(
53
54
  nodes: ReadonlyMap<string, TaskNode>,
54
55
  p50s: ReadonlyMap<string, number>,
55
56
  concurrency: number,
57
+ /** Each task's count of the tasks downstream of it (`blocksCounts`); computed here when absent. */
58
+ downstream: ReadonlyMap<string, number> = blocksCounts(nodes),
56
59
  ): Forecast {
57
60
  // Dependencies before dependents, once: each call is one pass over it.
58
61
  const order: TaskNode[] = []
@@ -88,22 +91,6 @@ export function createForecast(
88
91
  // graph order: once per run, as the scheduler computes it.
89
92
  const rank = computeReverseDepCount(nodes)
90
93
  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
94
  const before = (a: string, b: string): boolean => {
108
95
  const d = (rank.get(a) ?? 0) - (rank.get(b) ?? 0)
109
96
  return d !== 0 ? d > 0 : position.get(a)! < position.get(b)!
@@ -144,8 +131,6 @@ export function createForecast(
144
131
  since === undefined ? (head.get(n.id) ?? n.deps.find((d) => end.has(d))) : undefined
145
132
  if (by !== undefined && !isGroupTask(n)) {
146
133
  waiting++
147
- for (const on of direct.get(n.id) ?? [])
148
- if (running.has(on)) blockedBy.set(on, (blockedBy.get(on) ?? 0) + 1)
149
134
  let at = held.length
150
135
  while (at > 0 && before(n.id, held[at - 1]!.id)) at--
151
136
  if (at < workers) {
@@ -167,7 +152,11 @@ export function createForecast(
167
152
  }
168
153
  work += own
169
154
  }
170
- if (!known) return { waiting, expected, blocked: held, blockedBy }
155
+ for (const id of running.keys()) {
156
+ const d = downstream.get(id)
157
+ if (d !== undefined && d > 0) blockedBy.set(id, d)
158
+ }
159
+ if (!known) return { waiting, expected, blocked: held, blockedBy, usual: cost }
171
160
  const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
172
161
  return {
173
162
  etaMs: Math.max(chain, work / workers),
@@ -176,6 +165,18 @@ export function createForecast(
176
165
  expected,
177
166
  blocked: held,
178
167
  blockedBy,
168
+ usual: cost,
179
169
  }
180
170
  }
181
171
  }
172
+
173
+ /**
174
+ * Each task's `blocks`: how many tasks, a group's own looked through and
175
+ * none counted, sit downstream of it in the run, each once (2026-10-29:
176
+ * every task it holds back, not only the next ones).
177
+ */
178
+ export function blocksCounts(nodes: ReadonlyMap<string, TaskNode>): Map<string, number> {
179
+ const tasks = new Map<string, true>()
180
+ for (const n of nodes.values()) if (!isGroupTask(n)) tasks.set(n.id, true)
181
+ return computeReverseDepCount(nodes, tasks)
182
+ }