@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.
- package/README.md +10 -28
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/skills/vx/SKILL.md +1 -1
- package/src/bin.ts +1 -1
- package/src/cache/cache.ts +66 -16
- package/src/cache/git-inputs.ts +55 -10
- package/src/cache/index.ts +4 -0
- package/src/cache/inputs.ts +61 -11
- package/src/cache/layer.ts +25 -2
- package/src/cache/layered-cache.ts +32 -2
- package/src/cache/output-index.ts +25 -11
- package/src/cache/remote-lane.ts +13 -4
- package/src/cache/run-history.ts +13 -6
- package/src/cache/schema.ts +19 -2
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/help.ts +4 -6
- package/src/cli/index.ts +0 -12
- package/src/cli/init.ts +43 -10
- package/src/cli/last.ts +4 -2
- package/src/cli/plugin-commands.ts +96 -3
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +2 -7
- package/src/cli/select.ts +5 -3
- package/src/cli/upgrade.ts +4 -5
- package/src/exec/executor.ts +112 -9
- package/src/exec/index.ts +2 -0
- package/src/exec/proc-sample.ts +67 -5
- package/src/exec/runner.ts +4 -0
- package/src/exec/sandbox-runtime.ts +11 -4
- package/src/graph/scheduler.ts +59 -8
- package/src/history/history.ts +119 -7
- package/src/history/metrics.ts +53 -16
- package/src/index.ts +15 -0
- package/src/orchestrator/execute-args.ts +3 -0
- package/src/orchestrator/execute-task.ts +28 -7
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +11 -4
- package/src/orchestrator/index.ts +17 -1
- package/src/orchestrator/local-floor.ts +104 -0
- package/src/orchestrator/local-shortcircuit.ts +26 -7
- package/src/orchestrator/miss-save.ts +10 -4
- package/src/orchestrator/persistent-task.ts +14 -0
- package/src/orchestrator/placement.ts +84 -9
- package/src/orchestrator/plugin-host.ts +21 -3
- package/src/orchestrator/plugin.ts +37 -0
- package/src/orchestrator/prepare.ts +11 -3
- package/src/orchestrator/run-keys.ts +164 -0
- package/src/orchestrator/run-records.ts +6 -0
- package/src/orchestrator/run.ts +93 -6
- package/src/orchestrator/sandbox-request.ts +9 -3
- package/src/orchestrator/task-artifacts.ts +142 -0
- package/src/orchestrator/task-hash.ts +11 -1
- package/src/orchestrator/task-process.ts +16 -0
- package/src/orchestrator/telemetry-host.ts +2 -1
- package/src/orchestrator/telemetry.ts +60 -17
- package/src/report/forecast.ts +6 -27
- package/src/report/framed-output.ts +41 -5
- package/src/report/logger.ts +60 -20
- package/src/report/status-line.ts +38 -18
- package/src/report/summary.ts +61 -7
- package/src/settings.ts +7 -0
- package/src/util/colors.ts +16 -1
- package/src/util/index.ts +1 -0
- package/src/util/paths.ts +30 -0
- package/src/util/ui.ts +4 -1
- package/src/workspace/config-bytecode.ts +276 -0
- package/src/workspace/index.ts +0 -1
- package/src/workspace/migration.ts +4 -32
- package/src/workspace/project-loader.ts +92 -14
- 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) => () =>
|
|
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
|
|
480
|
-
// only while one runs; a tick still reading when the
|
|
481
|
-
// doubled.
|
|
482
|
-
|
|
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,
|
|
492
|
-
if (tracked.get(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(([,
|
|
529
|
+
const usage = await sampleTrees(entries.map(([, t]) => t.pid))
|
|
501
530
|
const ts = Date.now()
|
|
502
|
-
for (const [taskId,
|
|
503
|
-
if (ended || tracked.get(taskId) !==
|
|
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,
|
|
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): (() =>
|
|
526
|
-
if (ended) return () =>
|
|
527
|
-
|
|
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 () =>
|
|
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 {
|
package/src/report/forecast.ts
CHANGED
|
@@ -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
|
-
/**
|
|
14
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
) +
|
|
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
|
}
|
package/src/report/logger.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
368
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1109
|
+
emitRow(formatTaskSkippedLine(node, colors, outcome.blockedBy))
|
|
1070
1110
|
return
|
|
1071
1111
|
}
|
|
1072
1112
|
if (outcome.status !== 'failed') return
|
|
1073
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1146
|
+
emitRow(formatTaskExecutedLine(node, outcome, colors, onPath.has(node.id)))
|
|
1107
1147
|
} else if (outcome.status === 'skipped') {
|
|
1108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1210
|
+
emitRow(formatTaskSkippedLine(node, colors, outcome.blockedBy))
|
|
1171
1211
|
} else if (outcome.status === 'failed') {
|
|
1172
|
-
|
|
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
|
-
|
|
1234
|
+
emitRow(formatTaskHitLine(node, outcome, colors, onPath.has(node.id)))
|
|
1195
1235
|
return
|
|
1196
1236
|
}
|
|
1197
1237
|
if (outcome.status === 'skipped') {
|
|
1198
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
if (
|
|
487
|
-
|
|
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
|
-
) +
|
|
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)
|