@vzn/vx 0.0.645 → 0.0.646

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 (71) hide show
  1. package/README.md +10 -28
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/skills/vx/SKILL.md +1 -1
  5. package/src/bin.ts +1 -1
  6. package/src/cache/cache.ts +66 -16
  7. package/src/cache/git-inputs.ts +55 -10
  8. package/src/cache/index.ts +4 -0
  9. package/src/cache/inputs.ts +61 -11
  10. package/src/cache/layer.ts +25 -2
  11. package/src/cache/layered-cache.ts +32 -2
  12. package/src/cache/output-index.ts +25 -11
  13. package/src/cache/remote-lane.ts +13 -4
  14. package/src/cache/run-history.ts +13 -6
  15. package/src/cache/schema.ts +19 -2
  16. package/src/cli/core-alias.ts +5 -2
  17. package/src/cli/help.ts +4 -6
  18. package/src/cli/index.ts +0 -12
  19. package/src/cli/init.ts +43 -10
  20. package/src/cli/last.ts +4 -2
  21. package/src/cli/plugin-commands.ts +96 -3
  22. package/src/cli/plugin-templates.ts +1 -1
  23. package/src/cli/run.ts +2 -7
  24. package/src/cli/select.ts +5 -3
  25. package/src/cli/upgrade.ts +4 -5
  26. package/src/exec/executor.ts +112 -9
  27. package/src/exec/index.ts +2 -0
  28. package/src/exec/proc-sample.ts +67 -5
  29. package/src/exec/runner.ts +4 -0
  30. package/src/exec/sandbox-runtime.ts +11 -4
  31. package/src/graph/scheduler.ts +59 -8
  32. package/src/history/history.ts +119 -7
  33. package/src/history/metrics.ts +53 -16
  34. package/src/index.ts +15 -0
  35. package/src/orchestrator/execute-args.ts +3 -0
  36. package/src/orchestrator/execute-task.ts +28 -7
  37. package/src/orchestrator/held-servers.ts +86 -0
  38. package/src/orchestrator/hit-restore.ts +11 -4
  39. package/src/orchestrator/index.ts +17 -1
  40. package/src/orchestrator/local-floor.ts +104 -0
  41. package/src/orchestrator/local-shortcircuit.ts +26 -7
  42. package/src/orchestrator/miss-save.ts +10 -4
  43. package/src/orchestrator/persistent-task.ts +14 -0
  44. package/src/orchestrator/placement.ts +84 -9
  45. package/src/orchestrator/plugin-host.ts +21 -3
  46. package/src/orchestrator/plugin.ts +37 -0
  47. package/src/orchestrator/prepare.ts +11 -3
  48. package/src/orchestrator/run-keys.ts +164 -0
  49. package/src/orchestrator/run-records.ts +6 -0
  50. package/src/orchestrator/run.ts +93 -6
  51. package/src/orchestrator/sandbox-request.ts +9 -3
  52. package/src/orchestrator/task-artifacts.ts +142 -0
  53. package/src/orchestrator/task-hash.ts +11 -1
  54. package/src/orchestrator/task-process.ts +16 -0
  55. package/src/orchestrator/telemetry-host.ts +2 -1
  56. package/src/orchestrator/telemetry.ts +60 -17
  57. package/src/report/forecast.ts +6 -27
  58. package/src/report/framed-output.ts +41 -5
  59. package/src/report/logger.ts +60 -20
  60. package/src/report/status-line.ts +38 -18
  61. package/src/report/summary.ts +61 -7
  62. package/src/settings.ts +7 -0
  63. package/src/util/colors.ts +16 -1
  64. package/src/util/index.ts +1 -0
  65. package/src/util/paths.ts +30 -0
  66. package/src/util/ui.ts +4 -1
  67. package/src/workspace/config-bytecode.ts +276 -0
  68. package/src/workspace/index.ts +0 -1
  69. package/src/workspace/migration.ts +4 -32
  70. package/src/workspace/project-loader.ts +92 -14
  71. package/src/cli/foreign-flags.ts +0 -727
@@ -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 { sampleTrees } from '../exec/index.js'
19
+ import { sampleTrees, type MachineReport } from '../exec/index.js'
20
20
  import {
21
21
  type CacheSource,
22
22
  deriveCacheSource,
@@ -274,6 +274,12 @@ export interface RunSummaryRecord {
274
274
  * Absent when nothing was uploaded or tried. Additive.
275
275
  */
276
276
  uploads?: { count: number; bytes: number; ms: number; failed: number }
277
+ /**
278
+ * The machines an executor plugin ran tasks on, with what each did
279
+ * (`TaskExecutor.machines`); a task's `where` names one. Absent when no
280
+ * executor reports any. Additive.
281
+ */
282
+ machines?: readonly MachineReport[]
277
283
  }
278
284
 
279
285
  /** One stage of a run, a wall window in epoch ms. */
@@ -305,6 +311,7 @@ export function assembleRunSummary(
305
311
  abortedCount: number
306
312
  stages?: readonly RunStage[]
307
313
  uploads?: { count: number; bytes: number; ms: number; failed: number }
314
+ machines?: readonly MachineReport[]
308
315
  },
309
316
  ): RunSummaryRecord {
310
317
  let failedCount = 0
@@ -343,6 +350,9 @@ export function assembleRunSummary(
343
350
  ...(timing.uploads !== undefined && timing.uploads.count + timing.uploads.failed > 0
344
351
  ? { uploads: { ...timing.uploads } }
345
352
  : {}),
353
+ ...(timing.machines !== undefined && timing.machines.length > 0
354
+ ? { machines: timing.machines }
355
+ : {}),
346
356
  }
347
357
  }
348
358
 
@@ -400,8 +410,19 @@ export interface TelemetrySource {
400
410
  * Sample this task's process tree every `SAMPLE_MS` until the returned
401
411
  * function is called, the root exits, or the run ends. Present only when
402
412
  * a sink wants `task.sample`: nobody asking costs no timer and no read.
413
+ * The returned stop answers what the samples saw, for the run history.
403
414
  */
404
- readonly track?: (taskId: string, pid: number) => () => void
415
+ readonly track?: (taskId: string, pid: number) => () => TreeLoad | undefined
416
+ }
417
+
418
+ /**
419
+ * What a tracked task's samples saw: the most cores one interval between
420
+ * looks burned, and the mean resident memory over the looks. Undefined
421
+ * when no look landed (a task shorter than `SAMPLE_MS`).
422
+ */
423
+ export interface TreeLoad {
424
+ peakCores: number
425
+ avgRssBytes: number
405
426
  }
406
427
 
407
428
  /** How often a running task's process tree is sampled. */
@@ -476,10 +497,18 @@ export function createTelemetrySource(args: {
476
497
  const wantsLog = sinks.some((s) => (s.wants ?? DEFAULT_KINDS).includes('task.log'))
477
498
  const wantsSample = sinks.some((s) => (s.wants ?? DEFAULT_KINDS).includes('task.sample'))
478
499
 
479
- // taskId → the pid of its running root. One timer for every task, alive
480
- // only while one runs; a tick still reading when the next is due is not
481
- // doubled.
482
- const tracked = new Map<string, number>()
500
+ // taskId → its running root and what the looks at it saw. One timer for
501
+ // every task, alive only while one runs; a tick still reading when the
502
+ // next is due is not doubled.
503
+ interface Tracked {
504
+ pid: number
505
+ lastTs: number
506
+ lastCpuMs: number
507
+ peakCores: number
508
+ rssSum: number
509
+ looks: number
510
+ }
511
+ const tracked = new Map<string, Tracked>()
483
512
  const every = args.every ?? everyInterval
484
513
  let stopEvery: (() => void) | undefined
485
514
  let sampling = false
@@ -488,8 +517,8 @@ export function createTelemetrySource(args: {
488
517
  stopEvery?.()
489
518
  stopEvery = undefined
490
519
  }
491
- const untrack = (taskId: string, pid: number): void => {
492
- if (tracked.get(taskId) === pid) tracked.delete(taskId)
520
+ const untrack = (taskId: string, t: Tracked): void => {
521
+ if (tracked.get(taskId) === t) tracked.delete(taskId)
493
522
  if (tracked.size === 0) stopTimer()
494
523
  }
495
524
  const tick = async (): Promise<void> => {
@@ -497,15 +526,23 @@ export function createTelemetrySource(args: {
497
526
  sampling = true
498
527
  try {
499
528
  const entries = [...tracked]
500
- const usage = await sampleTrees(entries.map(([, pid]) => pid))
529
+ const usage = await sampleTrees(entries.map(([, t]) => t.pid))
501
530
  const ts = Date.now()
502
- for (const [taskId, pid] of entries) {
503
- if (ended || tracked.get(taskId) !== pid) continue
504
- const u = usage.get(pid)
531
+ for (const [taskId, t] of entries) {
532
+ if (ended || tracked.get(taskId) !== t) continue
533
+ const u = usage.get(t.pid)
505
534
  if (u === undefined) {
506
- untrack(taskId, pid)
535
+ untrack(taskId, t)
507
536
  continue
508
537
  }
538
+ // A descendant that exited takes its CPU out of the live sum, so an
539
+ // interval can read negative: it says nothing of the peak.
540
+ const ms = ts - t.lastTs
541
+ if (ms > 0) t.peakCores = Math.max(t.peakCores, (u.cpuMs - t.lastCpuMs) / ms)
542
+ t.lastTs = ts
543
+ t.lastCpuMs = u.cpuMs
544
+ t.rssSum += u.rssBytes
545
+ t.looks++
509
546
  deliver({
510
547
  v: TELEMETRY_SCHEMA_VERSION,
511
548
  kind: 'task.sample',
@@ -522,11 +559,17 @@ export function createTelemetrySource(args: {
522
559
  sampling = false
523
560
  }
524
561
  }
525
- const track = (taskId: string, pid: number): (() => void) => {
526
- if (ended) return () => {}
527
- tracked.set(taskId, pid)
562
+ const track = (taskId: string, pid: number): (() => TreeLoad | undefined) => {
563
+ if (ended) return () => undefined
564
+ const t: Tracked = { pid, lastTs: Date.now(), lastCpuMs: 0, peakCores: 0, rssSum: 0, looks: 0 }
565
+ tracked.set(taskId, t)
528
566
  stopEvery ??= every(SAMPLE_MS, tick)
529
- return () => untrack(taskId, pid)
567
+ return () => {
568
+ untrack(taskId, t)
569
+ return t.looks === 0
570
+ ? undefined
571
+ : { peakCores: t.peakCores, avgRssBytes: Math.round(t.rssSum / t.looks) }
572
+ }
530
573
  }
531
574
 
532
575
  function deliver(record: TelemetryRecord): void {
@@ -10,8 +10,8 @@ export interface LiveForecast {
10
10
  etaMs?: number
11
11
  /** The running task at the head of the longest unfinished chain. */
12
12
  critical?: string
13
- /** Each running task's unfinished dependents, direct and transitive. */
14
- blocks: ReadonlyMap<string, number>
13
+ /** Tasks not started because a dependency has not finished: the tasks line's `blocked`. */
14
+ waiting: number
15
15
  /** Each running task's p50, where history knows it: the row's expected duration. */
16
16
  expected?: ReadonlyMap<string, number>
17
17
  /**
@@ -89,32 +89,10 @@ export function createForecast(
89
89
  return d !== 0 ? d > 0 : position.get(a)! < position.get(b)!
90
90
  }
91
91
 
92
- const dependents = new Map<string, string[]>()
93
- for (const n of order)
94
- for (const d of n.deps) {
95
- const list = dependents.get(d)
96
- if (list === undefined) dependents.set(d, [n.id])
97
- else list.push(n.id)
98
- }
99
-
100
- const blocked = (id: string, finished: ReadonlySet<string>): number => {
101
- const seen = new Set<string>()
102
- const stack = [id]
103
- while (stack.length > 0) {
104
- for (const next of dependents.get(stack.pop()!) ?? []) {
105
- if (seen.has(next) || finished.has(next)) continue
106
- seen.add(next)
107
- stack.push(next)
108
- }
109
- }
110
- return seen.size
111
- }
112
-
113
92
  return (finished, running, nowMs) => {
114
- const blocks = new Map<string, number>()
115
93
  const expected = new Map<string, number>()
94
+ let waiting = 0
116
95
  for (const id of running.keys()) {
117
- blocks.set(id, blocked(id, finished))
118
96
  const p = cost.get(id)
119
97
  if (p !== undefined) expected.set(id, p)
120
98
  }
@@ -145,6 +123,7 @@ export function createForecast(
145
123
  const by =
146
124
  since === undefined ? (head.get(n.id) ?? n.deps.find((d) => end.has(d))) : undefined
147
125
  if (by !== undefined && !isGroupTask(n)) {
126
+ waiting++
148
127
  let at = held.length
149
128
  while (at > 0 && before(n.id, held[at - 1]!.id)) at--
150
129
  if (at < workers) {
@@ -167,12 +146,12 @@ export function createForecast(
167
146
  }
168
147
  work += own
169
148
  }
170
- if (!known || overrun) return { blocks, expected, blocked: held }
149
+ if (!known || overrun) return { waiting, expected, blocked: held }
171
150
  const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
172
151
  return {
173
152
  etaMs: Math.max(chain, work / workers),
174
153
  ...(critical !== undefined ? { critical } : {}),
175
- blocks,
154
+ waiting,
176
155
  expected,
177
156
  blocked: held,
178
157
  }
@@ -32,6 +32,7 @@ import {
32
32
  identityColor,
33
33
  maskedCommand,
34
34
  paint,
35
+ ui,
35
36
  type ColorSupport,
36
37
  } from '../util/index.js'
37
38
  import { neverStarted } from './summary.js'
@@ -124,7 +125,9 @@ export function formatTaskBlock(
124
125
 
125
126
  lines.push(...violationSection(outcome, colors))
126
127
 
127
- lines.push(`${corner('└─')} ${idPainted} ${corner('──')}${formatBlockFooter(outcome, colors)}`)
128
+ lines.push(
129
+ `${corner('└─')} ${idPainted}${whereNote(outcome, colors)} ${corner('──')}${formatBlockFooter(outcome, colors)}`,
130
+ )
128
131
  return lines.join('\n') + '\n'
129
132
  }
130
133
 
@@ -362,13 +365,23 @@ export function formatTaskRow(
362
365
  return ` ${glyph} ${timeCell(ms, colors)} ${st} ${ca} ${paintedId}`
363
366
  }
364
367
 
368
+ /**
369
+ * The dim header over a task list, on the row grid (M, 2026-10-21).
370
+ * `agent` when an executor places tasks: the name after the id's dot.
371
+ */
372
+ export function formatListHeader(agent: boolean, colors: ColorSupport = NO_COLOR): string {
373
+ const head = ` ${'time'.padStart(TIME_COL)} ${'status'.padEnd(STATUS_COL)} ${'cache'.padEnd(CACHE_COL)} task${agent ? ' \u00b7 agent' : ''}`
374
+ return paint('', head, colors, { dim: true })
375
+ }
376
+
365
377
  /** Compact one-liner for a cache hit with nothing to replay. */
366
378
  export function formatTaskHitLine(
367
379
  node: TaskNode,
368
380
  o: TaskOutcome,
369
381
  colors: ColorSupport = NO_COLOR,
382
+ onPath = false,
370
383
  ): string {
371
- return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors)
384
+ return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath)
372
385
  }
373
386
 
374
387
  /** Compact one-liner for an executed task in broad mode. */
@@ -376,8 +389,9 @@ export function formatTaskExecutedLine(
376
389
  node: TaskNode,
377
390
  o: TaskOutcome,
378
391
  colors: ColorSupport = NO_COLOR,
392
+ onPath = false,
379
393
  ): string {
380
- return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors)
394
+ return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath)
381
395
  }
382
396
 
383
397
  /** Shared: render any outcome on the grid. */
@@ -386,6 +400,7 @@ function formatOutcomeRow(
386
400
  paintedId: string,
387
401
  ms: number | null,
388
402
  colors: ColorSupport,
403
+ onPath: boolean,
389
404
  ): string {
390
405
  const st = statusOf(o)
391
406
  const ca = cacheOf(o)
@@ -399,10 +414,31 @@ function formatOutcomeRow(
399
414
  ca.color,
400
415
  paintedId,
401
416
  colors,
402
- ) + flakyNote(o, colors)
417
+ ) +
418
+ (onPath ? pathNote(colors) : '') +
419
+ whereNote(o, colors) +
420
+ flakyNote(o, colors)
403
421
  )
404
422
  }
405
423
 
424
+ /** `critical path`: the row's task held the run's end while others ran beside it. */
425
+ export function pathNote(colors: ColorSupport = NO_COLOR): string {
426
+ return ` ${paint(WARN, 'critical path', colors)}`
427
+ }
428
+
429
+ /** Where an executor ran the task, when not here, right after its id: `@small#2` (M, 2026-10-21). */
430
+ export function whereNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): string {
431
+ return o.where === undefined ? '' : placeNote(o.where, colors)
432
+ }
433
+
434
+ /**
435
+ * ` · <where>` after the id: the dot dim, the place in its own hue. Last
436
+ * on the row, as an agent's name has no width to pad to (M, 2026-10-21).
437
+ */
438
+ export function placeNote(where: string, colors: ColorSupport = NO_COLOR): string {
439
+ return `${paint('', ' \u00b7', colors, { dim: true })} ${ui.inline(ui.place(where), colors)}`
440
+ }
441
+
406
442
  /**
407
443
  * The dim note a flaky task's row and frame carry (`flaky - failed 1×
408
444
  * before`): nothing prints below the footer, so the task's own line is
@@ -568,7 +604,7 @@ export function formatFrameClose(
568
604
  persistent && outcome.status === 'success'
569
605
  ? ` ${paint('', `(${formatElapsed(outcome.durationMs)})`, colors, { dim: true })} ${paint(ACCENT, 'running', colors)}`
570
606
  : formatBlockFooter(outcome, colors)
571
- const close = `${corner('└─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('──')}${tail}`
607
+ const close = `${corner('└─')} ${mark}${paintTaskId(node, colors, { bold: true })}${whereNote(outcome, colors)} ${corner('──')}${tail}`
572
608
  const violations = violationSection(outcome, colors)
573
609
  return violations.length === 0 ? close : `${violations.join('\n')}\n${close}`
574
610
  }
@@ -5,10 +5,13 @@ import {
5
5
  formatFrameOpen,
6
6
  formatKeptLines,
7
7
  formatPersistentTailBlock,
8
+ formatListHeader,
8
9
  formatTaskAbortedLine,
9
10
  formatTaskBlock,
10
11
  formatTaskExecutedLine,
11
12
  flakyNote,
13
+ pathNote,
14
+ whereNote,
12
15
  formatTaskHitLine,
13
16
  formatTaskSkippedLine,
14
17
  type OutputChunk,
@@ -346,7 +349,9 @@ export function defaultLogger(
346
349
  let forecastTimer: ReturnType<typeof setTimeout> | null = null
347
350
  let etaMs: number | undefined
348
351
  let critical: string | undefined
349
- let blocks: ReadonlyMap<string, number> | undefined
352
+ // Tasks the region showed on the critical path: their finished rows say so too.
353
+ const onPath = new Set<string>()
354
+ let blockedCount = 0
350
355
  let expected: ReadonlyMap<string, number> | undefined
351
356
  let blocked: readonly BlockedTask[] | undefined
352
357
  // The blocked task each idle worker row holds, by slot: a row keeps its
@@ -364,8 +369,8 @@ export function defaultLogger(
364
369
  // one row per worker slot so a task's name never moves while it
365
370
  // runs (the display derives from the stable worker set, not the
366
371
  // churning task set). Sized at runStart from concurrency, capped at
367
- // 10 rows; excess running tasks queue for a freed slot and surface
368
- // as `+k more` on the stats line. Focused flow keeps its lifecycle:
372
+ // 10 rows; excess running tasks queue for a freed slot, each shown
373
+ // in its own row after the slots. Focused flow keeps its lifecycle:
369
374
  // the region dies the moment a requested node starts streaming.
370
375
  let slots: (WorkerSlot | null)[] = []
371
376
  // Rows shown: the most workers busy at once so far. An idle row past it
@@ -472,6 +477,15 @@ export function defaultLogger(
472
477
  if (statusDead) return
473
478
  let force = forced
474
479
  const now = Date.now()
480
+ // A task its executor still holds has not begun: its clock starts at
481
+ // the first frame a machine runs it.
482
+ const where = runContext?.whereRunning
483
+ if (where !== undefined)
484
+ for (const x of [...slots, ...slotQueue]) {
485
+ if (x === null) continue
486
+ x.waiting = where(x.id) === null
487
+ if (x.waiting) x.startedMs = now
488
+ }
475
489
  // A free row shows what it holds from the first frame: the graph
476
490
  // knows, without history (M, 2026-10-17).
477
491
  if (forecastFn === undefined && graphForecast !== undefined && !tail && !stopping)
@@ -491,7 +505,7 @@ export function defaultLogger(
491
505
  const f = forecastFn(finished, running, now)
492
506
  etaMs = f.etaMs
493
507
  critical = f.critical
494
- blocks = f.blocks
508
+ blockedCount = f.waiting
495
509
  expected = f.expected
496
510
  blocked = f.blocked
497
511
  }
@@ -509,6 +523,16 @@ export function defaultLogger(
509
523
  out.write(progressEscape(progressShown, progressRed))
510
524
  }
511
525
  }
526
+ let running = 0
527
+ let scheduled = 0
528
+ // Stopping, the rows say `aborting`: nothing is running or scheduled.
529
+ if (!tail && !stopping)
530
+ for (const x of [...slots, ...slotQueue]) {
531
+ if (x === null) continue
532
+ if (x.waiting === true) scheduled++
533
+ else running++
534
+ }
535
+ if (critical !== undefined && running > 1) onPath.add(critical)
512
536
  // The live summary IS the final summary's section, built from the
513
537
  // same formatter so the region visually becomes the printout.
514
538
  const summaryLines =
@@ -530,6 +554,9 @@ export function defaultLogger(
530
554
  miss: succeeded + failed - noCache,
531
555
  noCache,
532
556
  left: total - done - stopped - notRun,
557
+ ...(running > 0 ? { running } : {}),
558
+ ...(scheduled > 0 ? { scheduled } : {}),
559
+ ...(blockedCount > 0 && !tail && !stopping ? { blocked: blockedCount } : {}),
533
560
  ...(etaMs !== undefined && !tail && !stopping ? { etaMs } : {}),
534
561
  buckets,
535
562
  spread:
@@ -549,14 +576,15 @@ export function defaultLogger(
549
576
  formatStatusRegion(
550
577
  {
551
578
  pinnedPersistent,
552
- slots: slots.slice(0, shownSlots),
579
+ // Every running task has a row: one beyond the slots follows them (M, 2026-10-21).
580
+ slots: [...slots.slice(0, shownSlots), ...slotQueue],
553
581
  ...(held !== undefined ? { blocked: held } : {}),
554
- overflow: slotQueue.length,
555
582
  ...(stopping ? { stopping } : {}),
556
583
  ...(shownSteps.length > 0 ? { steps: shownSteps } : {}),
557
584
  nowMs: Date.now(),
558
585
  summaryLines,
559
- ...(blocks !== undefined ? { blocks } : {}),
586
+ ...(out.rows !== undefined && out.rows > 0 ? { rows: out.rows } : {}),
587
+ ...(runContext?.whereRunning !== undefined ? { where: runContext.whereRunning } : {}),
560
588
  ...(expected !== undefined ? { expected } : {}),
561
589
  ...(critical !== undefined ? { critical } : {}),
562
590
  },
@@ -644,6 +672,16 @@ export function defaultLogger(
644
672
  taskWrite(`${line}\n`)
645
673
  lineEmitted = true
646
674
  }
675
+ // The list's header, once over its first row, where the live region
676
+ // heads its own rows the same way (M, 2026-10-21).
677
+ let headed = !writer.enabled
678
+ const emitRow = (line: string): void => {
679
+ if (!headed) {
680
+ headed = true
681
+ emitLine(formatListHeader(runContext?.whereRunning !== undefined, colors))
682
+ }
683
+ emitLine(line)
684
+ }
647
685
  // formatTaskBlock returns '' for group tasks (no exec) — skip the
648
686
  // write so a stray newline doesn't sneak into the output.
649
687
  const emitBlock = (block: string): void => {
@@ -684,6 +722,8 @@ export function defaultLogger(
684
722
 
685
723
  const failureRow = (node: TaskNode, outcome: TaskOutcome): string =>
686
724
  formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(outcome)) +
725
+ (onPath.has(node.id) ? pathNote(colors) : '') +
726
+ whereNote(outcome, colors) +
687
727
  flakyNote(outcome, colors)
688
728
 
689
729
  return {
@@ -736,7 +776,7 @@ export function defaultLogger(
736
776
  steps.delete(id)
737
777
  if (durationMs < STEP_SHOWN_MS) return
738
778
  if (ok ? view.mode !== 'none' && view.mode !== 'errors-only' : view.mode !== 'none')
739
- emitLine(formatStepLine(id, durationMs, ok, colors))
779
+ emitRow(formatStepLine(id, durationMs, ok, colors))
740
780
  refresh(true)
741
781
  },
742
782
  runStop() {
@@ -981,7 +1021,7 @@ export function defaultLogger(
981
1021
  // Otherwise a row in the task list, in every view that lists a
982
1022
  // failure: the run exits red, and the row names why.
983
1023
  if (view.mode !== 'none' && view.mode !== 'hash-only') {
984
- emitLine(formatTaskAbortedLine(node, outcome, colors))
1024
+ emitRow(formatTaskAbortedLine(node, outcome, colors))
985
1025
  }
986
1026
  return
987
1027
  }
@@ -1066,11 +1106,11 @@ export function defaultLogger(
1066
1106
  }
1067
1107
  case 'errors-only':
1068
1108
  if (outcome.status === 'skipped') {
1069
- emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1109
+ emitRow(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1070
1110
  return
1071
1111
  }
1072
1112
  if (outcome.status !== 'failed') return
1073
- emitLine(failureRow(node, outcome))
1113
+ emitRow(failureRow(node, outcome))
1074
1114
  deferredFailures.push(
1075
1115
  formatTaskBlock(
1076
1116
  node,
@@ -1090,7 +1130,7 @@ export function defaultLogger(
1090
1130
  // surface in the end-of-run summary.
1091
1131
  if (outcome.status === 'failed') {
1092
1132
  // ◼ row now; the full frame replays at runEnd.
1093
- emitLine(failureRow(node, outcome))
1133
+ emitRow(failureRow(node, outcome))
1094
1134
  deferredFailures.push(
1095
1135
  formatTaskBlock(
1096
1136
  node,
@@ -1103,9 +1143,9 @@ export function defaultLogger(
1103
1143
  ),
1104
1144
  )
1105
1145
  } else if (outcome.status === 'success') {
1106
- emitLine(formatTaskExecutedLine(node, outcome, colors))
1146
+ emitRow(formatTaskExecutedLine(node, outcome, colors, onPath.has(node.id)))
1107
1147
  } else if (outcome.status === 'skipped') {
1108
- emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1148
+ emitRow(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1109
1149
  }
1110
1150
  return
1111
1151
  case 'focused':
@@ -1114,7 +1154,7 @@ export function defaultLogger(
1114
1154
  // stopped), so no frame-open fired — and a skip has no output,
1115
1155
  // so a one-liner carries everything a frame would.
1116
1156
  if (outcome.status === 'skipped') {
1117
- emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1157
+ emitRow(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1118
1158
  return
1119
1159
  }
1120
1160
  if (streamsLive(node)) {
@@ -1135,7 +1175,7 @@ export function defaultLogger(
1135
1175
  // to runEnd like everywhere else; everything else emits
1136
1176
  // ONE atomic block from the buffered output.
1137
1177
  if (outcome.status === 'failed') {
1138
- emitLine(failureRow(node, outcome))
1178
+ emitRow(failureRow(node, outcome))
1139
1179
  deferredFailures.push(
1140
1180
  formatTaskBlock(
1141
1181
  node,
@@ -1167,9 +1207,9 @@ export function defaultLogger(
1167
1207
  // Dependency-pulled nodes: silent on success; a skip gets its
1168
1208
  // row, failures the ◼ row now and their frame replayed at runEnd.
1169
1209
  if (outcome.status === 'skipped') {
1170
- emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1210
+ emitRow(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1171
1211
  } else if (outcome.status === 'failed') {
1172
- emitLine(failureRow(node, outcome))
1212
+ emitRow(failureRow(node, outcome))
1173
1213
  deferredFailures.push(
1174
1214
  formatTaskBlock(
1175
1215
  node,
@@ -1191,11 +1231,11 @@ export function defaultLogger(
1191
1231
  // point); misses/failures are always framed. One-liners
1192
1232
  // stay outside ::group:: — there's nothing to collapse.
1193
1233
  if (isHit && output.every((c) => c.text.trim().length === 0)) {
1194
- emitLine(formatTaskHitLine(node, outcome, colors))
1234
+ emitRow(formatTaskHitLine(node, outcome, colors, onPath.has(node.id)))
1195
1235
  return
1196
1236
  }
1197
1237
  if (outcome.status === 'skipped') {
1198
- emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1238
+ emitRow(formatTaskSkippedLine(node, colors, outcome.blockedBy))
1199
1239
  return
1200
1240
  }
1201
1241
  const block = formatTaskBlock(
@@ -5,8 +5,15 @@
5
5
  // ordinary write erases the status display first, writes its content,
6
6
  // then redraws, so the display can never interleave with task output.
7
7
 
8
- import { dumbTerminal, paint, type ColorSupport } from '../util/index.js'
9
- import { formatTaskRow, paintIdParts, TIME_COL } from './framed-output.js'
8
+ import { dumbTerminal, paint, ui, type ColorSupport } from '../util/index.js'
9
+ import {
10
+ formatListHeader,
11
+ formatTaskRow,
12
+ paintIdParts,
13
+ pathNote,
14
+ placeNote,
15
+ TIME_COL,
16
+ } from './framed-output.js'
10
17
  import type { BlockedTask } from './forecast.js'
11
18
 
12
19
  const NO_COLOR: ColorSupport = { enabled: false }
@@ -311,13 +318,17 @@ const IDLE = '#6b7280'
311
318
  export interface WorkerSlot {
312
319
  id: string
313
320
  startedMs: number
321
+ /** Its executor holds it for a machine to begin it (`TaskExecutor.running` is `null`). */
322
+ waiting?: boolean
314
323
  }
315
324
 
316
325
  export interface StatusRegionState {
317
- /** A running task's unfinished dependents (`blocks 14` on its row); 0 says nothing. */
318
- blocks?: ReadonlyMap<string, number>
326
+ /** The terminal's height: a region taller folds its rows, never its summary. */
327
+ rows?: number
319
328
  /** The running task at the head of the longest unfinished chain. */
320
329
  critical?: string
330
+ /** Where a running task runs, when not here (`TaskExecutor.running`); its row shows it. */
331
+ where?: (taskId: string) => string | null | undefined
321
332
  /** A running task's usual duration from history; its row shows it. */
322
333
  expected?: ReadonlyMap<string, number>
323
334
  /**
@@ -331,8 +342,6 @@ export interface StatusRegionState {
331
342
  slots: readonly (WorkerSlot | null)[]
332
343
  /** The blocked task an idle slot's row holds instead of `idle`, by the same index. */
333
344
  blocked?: readonly (BlockedTask | null)[]
334
- /** Running tasks beyond the displayed slots. */
335
- overflow: number
336
345
  /** The run was stopped: running rows say `aborting`, idle rows leave. */
337
346
  stopping?: boolean
338
347
  /**
@@ -451,9 +460,11 @@ export function formatStatusRegion(
451
460
  // place, dim, aligned under the status column: the blocked task the row
452
461
  // holds and what holds it back, else `idle`.
453
462
  // One task running has no path to compare against.
454
- const busy = s.slots.filter((x) => x !== null).length
463
+ const busy = s.slots.filter((x) => x !== null && x.waiting !== true).length
455
464
  for (const [row, slot] of s.slots.entries()) {
456
- if (slot === null && s.stopping === true) continue
465
+ // Placed by an executor (DTE), the list is what runs: blocked tasks are
466
+ // the tasks line's count alone (M, 2026-10-21).
467
+ if (slot === null && (s.stopping === true || s.where !== undefined)) continue
457
468
  if (slot === null) {
458
469
  const w = s.blocked?.[row]
459
470
  lines.push(
@@ -464,8 +475,6 @@ export function formatStatusRegion(
464
475
  )
465
476
  continue
466
477
  }
467
- // What waits on it, after the id: the row to watch is the one the
468
- // longest chain waits on, and the one the most tasks wait on.
469
478
  if (s.stopping === true) {
470
479
  lines.push(
471
480
  formatTaskRow(
@@ -481,10 +490,11 @@ export function formatStatusRegion(
481
490
  )
482
491
  continue
483
492
  }
484
- const waiting = s.blocks?.get(slot.id) ?? 0
485
- const notes: string[] = []
486
- if (s.critical === slot.id && busy > 1) notes.push(paint(WARN, 'critical path', colors))
487
- if (waiting > 0) notes.push(dim(`blocks ${waiting}`))
493
+ // Handed to an executor that has no machine on it yet: no row, only
494
+ // the tasks line's `scheduled` (M, 2026-10-21).
495
+ if (slot.waiting === true) continue
496
+ const at = s.where?.(slot.id)
497
+ const critical = s.critical === slot.id && busy > 1
488
498
  const usual = s.expected?.get(slot.id)
489
499
  lines.push(
490
500
  formatTaskRow(
@@ -496,12 +506,11 @@ export function formatStatusRegion(
496
506
  '',
497
507
  paintPinnedId(slot.id, colors),
498
508
  colors,
499
- ) + (notes.length > 0 ? ` ${notes.join(dim(' \u00b7 '))}` : ''),
509
+ ) +
510
+ (critical ? pathNote(colors) : '') +
511
+ (at != null ? placeNote(at, colors) : ''),
500
512
  )
501
513
  }
502
- if (s.overflow > 0) {
503
- lines.push(dim(`\u2026 +${s.overflow} more running`))
504
- }
505
514
  for (const step of s.steps ?? []) {
506
515
  lines.push(
507
516
  formatTaskRow(
@@ -516,6 +525,17 @@ export function formatStatusRegion(
516
525
  ),
517
526
  )
518
527
  }
528
+ if (lines.length > 1) lines.splice(1, 0, formatListHeader(s.where !== undefined, colors))
529
+ // A region taller than the terminal (a row spared) folds the rows past
530
+ // what fits into one line: the writer would cut the summary at its
531
+ // foot, and lines never printed cannot be scrolled to (M, 2026-10-21).
532
+ const room = s.rows === undefined ? Infinity : s.rows - 1 - s.summaryLines.length
533
+ if (lines.length > room && room >= 3) {
534
+ const kept = room - 1
535
+ const more = lines.length - kept
536
+ lines.length = kept
537
+ lines.push(`${' '.repeat(3 + TIME_COL + 1)}${dim(`+${more} more`)}`)
538
+ }
519
539
  // The live summary section — identical shape to the final printout,
520
540
  // so the region visually BECOMES the summary when the run ends.
521
541
  lines.push(...s.summaryLines)