@vzn/vx 0.0.622 → 0.0.624
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/package.json +5 -5
- package/src/cli/why.ts +60 -2
- package/src/orchestrator/forecast.ts +139 -0
- package/src/orchestrator/logger.ts +99 -0
- package/src/orchestrator/run.ts +12 -2
- package/src/orchestrator/status-line.ts +14 -1
- package/src/orchestrator/summary.ts +19 -0
- package/src/orchestrator/terminal-signals.ts +45 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.624",
|
|
4
4
|
"description": "The fastest task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.624",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.624",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.624",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.624"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cli/why.ts
CHANGED
|
@@ -9,7 +9,9 @@ import path from 'node:path'
|
|
|
9
9
|
import { Cache } from '../cache/index.js'
|
|
10
10
|
import { flagHint, formatValue, seeHelp } from './help.js'
|
|
11
11
|
import { splitTaskId } from '../graph/index.js'
|
|
12
|
+
import type { Database } from 'bun:sqlite'
|
|
12
13
|
import {
|
|
14
|
+
type CacheKeyDiff,
|
|
13
15
|
cacheKeyDiff,
|
|
14
16
|
explainCacheKeyQuery as explainCacheKey,
|
|
15
17
|
latestRunId,
|
|
@@ -232,9 +234,13 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
232
234
|
)
|
|
233
235
|
}
|
|
234
236
|
const diff = cacheKeyDiff(db, runId, taskId)
|
|
237
|
+
// An upstream change is never the cause, only its carrier: follow
|
|
238
|
+
// each moved dependency in this run down to the tasks whose own
|
|
239
|
+
// inputs moved, so one `vx why` names the file, not the next task.
|
|
240
|
+
const roots = rootCauses(db, runId, taskId, diff.entries)
|
|
235
241
|
|
|
236
242
|
if (parsed.format === 'json') {
|
|
237
|
-
process.stdout.write(`${JSON.stringify({ taskId, runId, why, diff })}\n`)
|
|
243
|
+
process.stdout.write(`${JSON.stringify({ taskId, runId, why, diff, roots })}\n`)
|
|
238
244
|
return 0
|
|
239
245
|
}
|
|
240
246
|
|
|
@@ -293,7 +299,22 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
293
299
|
` ${e.change.padEnd(7)} ${e.kind.padEnd(kindW)} ${printable(e.name)} ${beforeAfter}`.trimEnd(),
|
|
294
300
|
)
|
|
295
301
|
}
|
|
296
|
-
|
|
302
|
+
if (roots.length > 0) {
|
|
303
|
+
lines.push('', ' root cause:')
|
|
304
|
+
for (const r of roots.slice(0, ROOTS_SHOWN)) {
|
|
305
|
+
const what = r.entries.map((e) =>
|
|
306
|
+
e.name === e.kind ? e.kind : `${e.kind} ${printable(e.name)}`,
|
|
307
|
+
)
|
|
308
|
+
lines.push(
|
|
309
|
+
` ${r.chain.join(' \u2190 ')} \u2190 ${what.slice(0, 3).join(', ')}${what.length > 3 ? ` and ${what.length - 3} more` : ''}`,
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
if (roots.length > ROOTS_SHOWN) lines.push(` and ${roots.length - ROOTS_SHOWN} more`)
|
|
313
|
+
}
|
|
314
|
+
const rootKinds = roots.flatMap((r) => r.entries.map((e) => e.kind))
|
|
315
|
+
const kinds = [...new Set([...diff.entries.map((e) => e.kind), ...rootKinds])].filter(
|
|
316
|
+
(k) => k in WHAT_TO_DO && !(k === 'upstream' && roots.length > 0),
|
|
317
|
+
)
|
|
297
318
|
if (kinds.length > 0) {
|
|
298
319
|
lines.push('', ' what to do:')
|
|
299
320
|
// An upstream row names the task to ask next: the command, not a
|
|
@@ -321,3 +342,40 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
321
342
|
cache.close()
|
|
322
343
|
}
|
|
323
344
|
}
|
|
345
|
+
|
|
346
|
+
const ROOTS_SHOWN = 5
|
|
347
|
+
|
|
348
|
+
interface RootCause {
|
|
349
|
+
/** From the asked task down to the one whose own inputs moved. */
|
|
350
|
+
chain: string[]
|
|
351
|
+
entries: CacheKeyDiff['entries']
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The tasks under `taskId`'s moved upstreams whose OWN key components
|
|
356
|
+
* moved in the same run, each with the chain that carried it up. A task
|
|
357
|
+
* visited once is not walked again (a diamond names its root once).
|
|
358
|
+
*/
|
|
359
|
+
function rootCauses(
|
|
360
|
+
db: Database,
|
|
361
|
+
runId: string,
|
|
362
|
+
taskId: string,
|
|
363
|
+
entries: CacheKeyDiff['entries'],
|
|
364
|
+
): RootCause[] {
|
|
365
|
+
const roots: RootCause[] = []
|
|
366
|
+
const seen = new Set([taskId])
|
|
367
|
+
const walk = (chain: string[], moved: CacheKeyDiff['entries']): void => {
|
|
368
|
+
for (const e of moved) {
|
|
369
|
+
if (e.kind !== 'upstream' || seen.has(e.name)) continue
|
|
370
|
+
seen.add(e.name)
|
|
371
|
+
const next = [...chain, e.name]
|
|
372
|
+
const d = cacheKeyDiff(db, runId, e.name)
|
|
373
|
+
if (!d.found) continue
|
|
374
|
+
const own = d.entries.filter((x) => x.kind !== 'upstream')
|
|
375
|
+
if (own.length > 0) roots.push({ chain: next, entries: own })
|
|
376
|
+
walk(next, d.entries)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
walk([taskId], entries)
|
|
380
|
+
return roots
|
|
381
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// How long the rest of a live run should take, from each task's executed
|
|
2
|
+
// p50 in local history. A display aid for the live region only: it never
|
|
3
|
+
// steers the scheduler and never reaches the final summary.
|
|
4
|
+
|
|
5
|
+
import { isGroupTask, type TaskNode } from '../graph/index.js'
|
|
6
|
+
|
|
7
|
+
/** What the live region says about the rest of a run. */
|
|
8
|
+
export interface LiveForecast {
|
|
9
|
+
/** Remaining wall ms, absent when history knows none of what is left. */
|
|
10
|
+
etaMs?: number
|
|
11
|
+
/** The running task at the head of the longest unfinished chain. */
|
|
12
|
+
critical?: string
|
|
13
|
+
/** Each running task's unfinished dependents, direct and transitive. */
|
|
14
|
+
blocks: ReadonlyMap<string, number>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Forecast = (
|
|
18
|
+
finished: ReadonlySet<string>,
|
|
19
|
+
running: ReadonlyMap<string, number>,
|
|
20
|
+
nowMs: number,
|
|
21
|
+
) => LiveForecast
|
|
22
|
+
|
|
23
|
+
/** How far past its p50 a running task may go before the forecast gives up. */
|
|
24
|
+
const OVERRUN = 1.25
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The later of the longest unfinished chain and the unfinished work spread
|
|
28
|
+
* over the workers. A task still to run costs its p50 (a pending task may
|
|
29
|
+
* yet hit, so this leans long, and the hits finish first); a running one
|
|
30
|
+
* costs what its p50 has left, never below zero; a group or a task with no
|
|
31
|
+
* history costs nothing. A running task well past its p50 means history
|
|
32
|
+
* no longer describes this run, and the forecast says nothing of time.
|
|
33
|
+
*/
|
|
34
|
+
export function createForecast(
|
|
35
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
36
|
+
p50s: ReadonlyMap<string, number>,
|
|
37
|
+
concurrency: number,
|
|
38
|
+
): Forecast {
|
|
39
|
+
// Dependencies before dependents, once: each call is one pass over it.
|
|
40
|
+
const order: TaskNode[] = []
|
|
41
|
+
const placed = new Set<string>()
|
|
42
|
+
const place = (n: TaskNode): void => {
|
|
43
|
+
const stack: Array<[TaskNode, number]> = [[n, 0]]
|
|
44
|
+
while (stack.length > 0) {
|
|
45
|
+
const top = stack[stack.length - 1]!
|
|
46
|
+
const [node, i] = top
|
|
47
|
+
if (placed.has(node.id)) {
|
|
48
|
+
stack.pop()
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
if (i < node.deps.length) {
|
|
52
|
+
top[1] = i + 1
|
|
53
|
+
const dep = nodes.get(node.deps[i]!)
|
|
54
|
+
if (dep !== undefined && !placed.has(dep.id)) stack.push([dep, 0])
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
placed.add(node.id)
|
|
58
|
+
order.push(node)
|
|
59
|
+
stack.pop()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const n of nodes.values()) place(n)
|
|
63
|
+
const cost = new Map<string, number>()
|
|
64
|
+
for (const n of order) {
|
|
65
|
+
const p = isGroupTask(n) ? undefined : p50s.get(n.id)
|
|
66
|
+
if (p !== undefined) cost.set(n.id, p)
|
|
67
|
+
}
|
|
68
|
+
const workers = Math.max(1, concurrency)
|
|
69
|
+
|
|
70
|
+
const dependents = new Map<string, string[]>()
|
|
71
|
+
for (const n of order)
|
|
72
|
+
for (const d of n.deps) {
|
|
73
|
+
const list = dependents.get(d)
|
|
74
|
+
if (list === undefined) dependents.set(d, [n.id])
|
|
75
|
+
else list.push(n.id)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const blocked = (id: string, finished: ReadonlySet<string>): number => {
|
|
79
|
+
const seen = new Set<string>()
|
|
80
|
+
const stack = [id]
|
|
81
|
+
while (stack.length > 0) {
|
|
82
|
+
for (const next of dependents.get(stack.pop()!) ?? []) {
|
|
83
|
+
if (seen.has(next) || finished.has(next)) continue
|
|
84
|
+
seen.add(next)
|
|
85
|
+
stack.push(next)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return seen.size
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (finished, running, nowMs) => {
|
|
92
|
+
const blocks = new Map<string, number>()
|
|
93
|
+
for (const id of running.keys()) blocks.set(id, blocked(id, finished))
|
|
94
|
+
const end = new Map<string, number>()
|
|
95
|
+
// The running task each node's longest chain waits on.
|
|
96
|
+
const head = new Map<string, string>()
|
|
97
|
+
let chain = 0
|
|
98
|
+
let chainEnd: string | undefined
|
|
99
|
+
let work = 0
|
|
100
|
+
let known = false
|
|
101
|
+
let overrun = false
|
|
102
|
+
for (const n of order) {
|
|
103
|
+
if (finished.has(n.id)) continue
|
|
104
|
+
let start = 0
|
|
105
|
+
let via: string | undefined
|
|
106
|
+
for (const d of n.deps) {
|
|
107
|
+
const e = end.get(d)
|
|
108
|
+
if (e !== undefined && (via === undefined || e > start)) {
|
|
109
|
+
start = e
|
|
110
|
+
via = d
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const since = running.get(n.id)
|
|
114
|
+
if (since !== undefined) head.set(n.id, n.id)
|
|
115
|
+
else if (via !== undefined && head.has(via)) head.set(n.id, head.get(via)!)
|
|
116
|
+
const p = cost.get(n.id)
|
|
117
|
+
let own = 0
|
|
118
|
+
if (p !== undefined) {
|
|
119
|
+
known = true
|
|
120
|
+
if (since !== undefined && nowMs - since > p * OVERRUN) overrun = true
|
|
121
|
+
own = since === undefined ? p : Math.max(0, p - (nowMs - since))
|
|
122
|
+
}
|
|
123
|
+
const e = start + own
|
|
124
|
+
end.set(n.id, e)
|
|
125
|
+
if (e > chain || chainEnd === undefined) {
|
|
126
|
+
chain = e
|
|
127
|
+
chainEnd = n.id
|
|
128
|
+
}
|
|
129
|
+
work += own
|
|
130
|
+
}
|
|
131
|
+
if (!known || overrun) return { blocks }
|
|
132
|
+
const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
|
|
133
|
+
return {
|
|
134
|
+
etaMs: Math.max(chain, work / workers),
|
|
135
|
+
...(critical !== undefined ? { critical } : {}),
|
|
136
|
+
blocks,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -31,6 +31,13 @@ import {
|
|
|
31
31
|
} from '../util/index.js'
|
|
32
32
|
import { isCacheHit } from './telemetry.js'
|
|
33
33
|
import { failedLabel, outcomeWord, ranNoCache } from './events.js'
|
|
34
|
+
import type { Forecast } from './forecast.js'
|
|
35
|
+
import {
|
|
36
|
+
notifyEscape,
|
|
37
|
+
PROGRESS_CLEAR,
|
|
38
|
+
progressEscape,
|
|
39
|
+
terminalSignals,
|
|
40
|
+
} from './terminal-signals.js'
|
|
34
41
|
|
|
35
42
|
export interface Logger {
|
|
36
43
|
/** Header / footer / status text. Written verbatim, one trailing \n added. */
|
|
@@ -71,6 +78,12 @@ export interface DefaultLogger extends Logger {
|
|
|
71
78
|
* throw that never reached `runEnd`.
|
|
72
79
|
*/
|
|
73
80
|
settle(): void
|
|
81
|
+
/**
|
|
82
|
+
* Hand the live region a way to predict the run's end. Called once at
|
|
83
|
+
* run start; `load` runs only if the region is still live a second in,
|
|
84
|
+
* so a run that ends sooner never reads history for it.
|
|
85
|
+
*/
|
|
86
|
+
forecast(load: () => Promise<Forecast>): void
|
|
74
87
|
}
|
|
75
88
|
|
|
76
89
|
/**
|
|
@@ -168,6 +181,11 @@ export function resolveOutputView(
|
|
|
168
181
|
return mk('full')
|
|
169
182
|
}
|
|
170
183
|
|
|
184
|
+
/** A run shorter than this never reads history for a forecast. */
|
|
185
|
+
const FORECAST_AFTER_MS = 1000
|
|
186
|
+
/** A run shorter than this ends without a desktop notification. */
|
|
187
|
+
const NOTIFY_AFTER_MS = 10_000
|
|
188
|
+
|
|
171
189
|
export function defaultLogger(
|
|
172
190
|
colors: ColorSupport = detectColors(),
|
|
173
191
|
view: OutputView = { mode: 'full' },
|
|
@@ -178,6 +196,8 @@ export function defaultLogger(
|
|
|
178
196
|
tty?: boolean
|
|
179
197
|
/** The args after `--`: a requested task's `$ <command>` line shows them, as it ran. */
|
|
180
198
|
forwardArgs?: readonly string[]
|
|
199
|
+
/** The environment that names the terminal (`TERM_PROGRAM`, …); `process.env` by default. */
|
|
200
|
+
env?: Readonly<Record<string, string | undefined>>
|
|
181
201
|
} = {},
|
|
182
202
|
): DefaultLogger {
|
|
183
203
|
const forwardArgs = opts.forwardArgs ?? []
|
|
@@ -266,6 +286,20 @@ export function defaultLogger(
|
|
|
266
286
|
let startedAtMs = Date.now()
|
|
267
287
|
let ticker: ReturnType<typeof setInterval> | null = null
|
|
268
288
|
let statusDead = !writer.enabled
|
|
289
|
+
// The live run's forecast (live region only). Finished ids feed it; the
|
|
290
|
+
// set is kept only where a region can show the answer.
|
|
291
|
+
const finished = new Set<string>()
|
|
292
|
+
let forecastFn: Forecast | undefined
|
|
293
|
+
let forecastTimer: ReturnType<typeof setTimeout> | null = null
|
|
294
|
+
let etaMs: number | undefined
|
|
295
|
+
let critical: string | undefined
|
|
296
|
+
let blocks: ReadonlyMap<string, number> | undefined
|
|
297
|
+
let etaAtMs = 0
|
|
298
|
+
const signals = writer.enabled
|
|
299
|
+
? terminalSignals(opts.env ?? process.env)
|
|
300
|
+
: { progress: false, notify: false }
|
|
301
|
+
let progressShown = -1
|
|
302
|
+
let progressRed = false
|
|
269
303
|
|
|
270
304
|
// Every interactive view renders the fixed-height worker region:
|
|
271
305
|
// one row per worker slot so a task's name never moves while it
|
|
@@ -357,6 +391,32 @@ export function defaultLogger(
|
|
|
357
391
|
|
|
358
392
|
const refresh = (force: boolean): void => {
|
|
359
393
|
if (statusDead) return
|
|
394
|
+
const now = Date.now()
|
|
395
|
+
// Twice a second at most: one pass over the unfinished graph.
|
|
396
|
+
if (forecastFn !== undefined && (force || now - etaAtMs >= 500)) {
|
|
397
|
+
etaAtMs = now
|
|
398
|
+
const running = new Map<string, number>()
|
|
399
|
+
for (const s of slots) if (s !== null) running.set(s.id, s.startedMs)
|
|
400
|
+
for (const s of slotQueue) running.set(s.id, s.startedMs)
|
|
401
|
+
const f = forecastFn(finished, running, now)
|
|
402
|
+
etaMs = f.etaMs
|
|
403
|
+
critical = f.critical
|
|
404
|
+
blocks = f.blocks
|
|
405
|
+
}
|
|
406
|
+
if (signals.progress && total > 0) {
|
|
407
|
+
const elapsed = now - startedAtMs
|
|
408
|
+
const pct = Math.floor(
|
|
409
|
+
etaMs !== undefined && elapsed + etaMs > 0
|
|
410
|
+
? (elapsed / (elapsed + etaMs)) * 100
|
|
411
|
+
: (done / total) * 100,
|
|
412
|
+
)
|
|
413
|
+
// Never backwards: a forecast that gives up falls back to the count.
|
|
414
|
+
if (pct > progressShown || failed > 0 !== progressRed) {
|
|
415
|
+
progressShown = Math.max(pct, progressShown)
|
|
416
|
+
progressRed = failed > 0
|
|
417
|
+
out.write(progressEscape(progressShown, progressRed))
|
|
418
|
+
}
|
|
419
|
+
}
|
|
360
420
|
// The live summary IS the final summary's section, built from the
|
|
361
421
|
// same formatter so the region visually becomes the printout.
|
|
362
422
|
const summaryLines = formatSummarySection(
|
|
@@ -372,6 +432,7 @@ export function defaultLogger(
|
|
|
372
432
|
noCache,
|
|
373
433
|
savedMs,
|
|
374
434
|
left: total - done,
|
|
435
|
+
...(etaMs !== undefined ? { etaMs } : {}),
|
|
375
436
|
spread:
|
|
376
437
|
spreadCount > 0
|
|
377
438
|
? { maxMs: spreadMax, minMs: spreadMin, sumMs: spreadSum, count: spreadCount }
|
|
@@ -389,6 +450,8 @@ export function defaultLogger(
|
|
|
389
450
|
overflow: slotQueue.length,
|
|
390
451
|
nowMs: Date.now(),
|
|
391
452
|
summaryLines,
|
|
453
|
+
...(blocks !== undefined ? { blocks } : {}),
|
|
454
|
+
...(critical !== undefined ? { critical } : {}),
|
|
392
455
|
},
|
|
393
456
|
colors,
|
|
394
457
|
),
|
|
@@ -400,6 +463,14 @@ export function defaultLogger(
|
|
|
400
463
|
clearInterval(ticker)
|
|
401
464
|
ticker = null
|
|
402
465
|
}
|
|
466
|
+
if (forecastTimer !== null) {
|
|
467
|
+
clearTimeout(forecastTimer)
|
|
468
|
+
forecastTimer = null
|
|
469
|
+
}
|
|
470
|
+
if (progressShown >= 0) {
|
|
471
|
+
progressShown = -1
|
|
472
|
+
out.write(PROGRESS_CLEAR)
|
|
473
|
+
}
|
|
403
474
|
if (statusDead) return
|
|
404
475
|
statusDead = true
|
|
405
476
|
writer.clearStatus()
|
|
@@ -516,7 +587,34 @@ export function defaultLogger(
|
|
|
516
587
|
else slotQueue.push(slot)
|
|
517
588
|
refresh(true)
|
|
518
589
|
},
|
|
590
|
+
forecast(load) {
|
|
591
|
+
if (statusDead || forecastTimer !== null) return
|
|
592
|
+
forecastTimer = setTimeout(() => {
|
|
593
|
+
forecastTimer = null
|
|
594
|
+
if (statusDead) return
|
|
595
|
+
load().then(
|
|
596
|
+
(f) => {
|
|
597
|
+
forecastFn = f
|
|
598
|
+
if (!statusDead) refresh(true)
|
|
599
|
+
},
|
|
600
|
+
// History is observability: an unreadable one forecasts nothing.
|
|
601
|
+
() => {},
|
|
602
|
+
)
|
|
603
|
+
}, FORECAST_AFTER_MS)
|
|
604
|
+
forecastTimer.unref?.()
|
|
605
|
+
},
|
|
519
606
|
runEnd() {
|
|
607
|
+
// A long run says it ended where the user is not looking. Once:
|
|
608
|
+
// the status is dead after the first runEnd.
|
|
609
|
+
if (signals.notify && !statusDead && Date.now() - startedAtMs >= NOTIFY_AFTER_MS) {
|
|
610
|
+
out.write(
|
|
611
|
+
notifyEscape(
|
|
612
|
+
failed > 0
|
|
613
|
+
? `vx: ${failed} of ${total} task${total === 1 ? '' : 's'} failed`
|
|
614
|
+
: `vx: ${total} task${total === 1 ? '' : 's'} done`,
|
|
615
|
+
),
|
|
616
|
+
)
|
|
617
|
+
}
|
|
520
618
|
killStatus()
|
|
521
619
|
// Persistent tails first: they are context for whatever ran against
|
|
522
620
|
// the server, so they read above the failures they explain. `none`
|
|
@@ -603,6 +701,7 @@ export function defaultLogger(
|
|
|
603
701
|
pushChunk(stderrBuffers, node.id, chunk)
|
|
604
702
|
},
|
|
605
703
|
taskComplete(node, outcome) {
|
|
704
|
+
if (!statusDead) finished.add(node.id)
|
|
606
705
|
// A persistent task buffered into its bounded tail rather than the
|
|
607
706
|
// per-task buffers (see taskStart), so its frame is drained from
|
|
608
707
|
// there — carrying how much the cap dropped, since a truncated log
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -63,10 +63,11 @@ import { buildAdmission, executorLabel, resolveExecutors, teardownPlugins } from
|
|
|
63
63
|
import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
64
64
|
import { assembleRunSummary, isPassStatus } from './telemetry.js'
|
|
65
65
|
import type { RunContextRecord } from './telemetry.js'
|
|
66
|
-
import { defaultLogger, resolveOutputView, type Logger } from './logger.js'
|
|
66
|
+
import { defaultLogger, type DefaultLogger, resolveOutputView, type Logger } from './logger.js'
|
|
67
67
|
import { detectColors, type ColorSupport } from './colors.js'
|
|
68
68
|
import { plainOutput } from './plain-output.js'
|
|
69
69
|
import { formatPersistentList } from './framed-output.js'
|
|
70
|
+
import { createForecast } from './forecast.js'
|
|
70
71
|
import { LocalHistoryProvider } from './history.js'
|
|
71
72
|
import { plan, type RunPlan } from './plan.js'
|
|
72
73
|
import { prepareRun, type PreparedRun } from './prepare.js'
|
|
@@ -339,7 +340,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
339
340
|
}
|
|
340
341
|
let held = false
|
|
341
342
|
try {
|
|
342
|
-
const summary = await runOnBus(options, bus, colors)
|
|
343
|
+
const summary = await runOnBus(options, bus, colors, terminal)
|
|
343
344
|
if (summary.persistent === undefined) return summary
|
|
344
345
|
// Servers handed back still running still write: `vx watch dev` printed
|
|
345
346
|
// none of its server's log while it idled, the renderer gone with this
|
|
@@ -362,6 +363,7 @@ async function runOnBus(
|
|
|
362
363
|
options: RunOptions,
|
|
363
364
|
bus: EventBus,
|
|
364
365
|
colors: ColorSupport,
|
|
366
|
+
terminal: DefaultLogger | null,
|
|
365
367
|
): Promise<RunSummary> {
|
|
366
368
|
const log = busLogger(bus)
|
|
367
369
|
// A server's output goes through a cell of its own: a later watch cycle
|
|
@@ -889,6 +891,14 @@ async function runOnBus(
|
|
|
889
891
|
context: runContext,
|
|
890
892
|
startedAtMs: endedAtMsAtStart,
|
|
891
893
|
})
|
|
894
|
+
// The live region's end-of-run forecast. The logger asks only if its
|
|
895
|
+
// region is still up a second in, so a warm run never reads history.
|
|
896
|
+
terminal?.forecast(async () => {
|
|
897
|
+
const ids: string[] = []
|
|
898
|
+
for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
|
|
899
|
+
const p50s = await new LocalHistoryProvider(prepared.localCache.dbHandle()).p50sFor(ids)
|
|
900
|
+
return createForecast(nodes, p50s, concurrency)
|
|
901
|
+
})
|
|
892
902
|
|
|
893
903
|
// Remote-only: kick off background prefetches so remote-GET latency
|
|
894
904
|
// overlaps execution. Fire-and-forget — execution starts on the next
|
|
@@ -12,6 +12,7 @@ const NO_COLOR: ColorSupport = { enabled: false }
|
|
|
12
12
|
// Same palette as framed-output.ts so glyphs and stats agree.
|
|
13
13
|
const ACCENT = '#06b6d4'
|
|
14
14
|
const ERROR = '#ef4444'
|
|
15
|
+
const WARN = '#eab308' // yellow-500, as the summary's
|
|
15
16
|
|
|
16
17
|
/** Erase the current line and return the cursor to column 0. */
|
|
17
18
|
const CLEAR = '\x1b[2K\r'
|
|
@@ -241,6 +242,10 @@ export interface WorkerSlot {
|
|
|
241
242
|
}
|
|
242
243
|
|
|
243
244
|
export interface StatusRegionState {
|
|
245
|
+
/** A running task's unfinished dependents (`blocks 14` on its row); 0 says nothing. */
|
|
246
|
+
blocks?: ReadonlyMap<string, number>
|
|
247
|
+
/** The running task at the head of the longest unfinished chain. */
|
|
248
|
+
critical?: string
|
|
244
249
|
/**
|
|
245
250
|
* Ready persistent tasks (dev servers …) whose children keep
|
|
246
251
|
* running after their outcome lands. Pinned until runEnd — the
|
|
@@ -331,11 +336,19 @@ export function formatStatusRegion(
|
|
|
331
336
|
// the ticking time IS the motion), then `running`, full id (never
|
|
332
337
|
// truncated — name is the last column). Idle rows hold their slot's
|
|
333
338
|
// place, dim, aligned under the status column.
|
|
339
|
+
// One task running has no path to compare against.
|
|
340
|
+
const busy = s.slots.filter((x) => x !== null).length
|
|
334
341
|
for (const slot of s.slots) {
|
|
335
342
|
if (slot === null) {
|
|
336
343
|
lines.push(`${' '.repeat(3 + TIME_COL + 1)}${paint(IDLE, 'idle', colors, { dim: true })}`)
|
|
337
344
|
continue
|
|
338
345
|
}
|
|
346
|
+
// What waits on it, after the id: the row to watch is the one the
|
|
347
|
+
// longest chain waits on, and the one the most tasks wait on.
|
|
348
|
+
const waiting = s.blocks?.get(slot.id) ?? 0
|
|
349
|
+
const notes: string[] = []
|
|
350
|
+
if (s.critical === slot.id && busy > 1) notes.push(paint(WARN, 'critical path', colors))
|
|
351
|
+
if (waiting > 0) notes.push(dim(`blocks ${waiting}`))
|
|
339
352
|
lines.push(
|
|
340
353
|
formatTaskRow(
|
|
341
354
|
' ',
|
|
@@ -346,7 +359,7 @@ export function formatStatusRegion(
|
|
|
346
359
|
'',
|
|
347
360
|
paintPinnedId(slot.id, colors),
|
|
348
361
|
colors,
|
|
349
|
-
),
|
|
362
|
+
) + (notes.length > 0 ? ` ${notes.join(dim(' \u00b7 '))}` : ''),
|
|
350
363
|
)
|
|
351
364
|
}
|
|
352
365
|
if (s.overflow > 0) {
|
|
@@ -21,6 +21,7 @@ const LOCAL = '#38bdf8' // sky-400
|
|
|
21
21
|
const REMOTE = '#2563eb' // blue-600
|
|
22
22
|
const ERROR = '#ef4444'
|
|
23
23
|
const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
|
|
24
|
+
const ACCENT = '#06b6d4' // cyan-500: the live region's running word
|
|
24
25
|
|
|
25
26
|
const BAR_WIDTH = 50
|
|
26
27
|
// Brand gradient for the rule: identity violet → pink (the project /
|
|
@@ -120,6 +121,8 @@ export interface SummaryStats {
|
|
|
120
121
|
spread: { maxMs: number; minMs: number; sumMs: number; count: number } | null
|
|
121
122
|
/** Tasks an `admit` policy held with a worker free, and the waits summed. Absent with no hold. */
|
|
122
123
|
held?: { count: number; sumMs: number }
|
|
124
|
+
/** Live section only: the forecast of what is left, from history. */
|
|
125
|
+
etaMs?: number
|
|
123
126
|
/** The hits' stored exec times summed: the work the cache skipped. */
|
|
124
127
|
savedMs?: number
|
|
125
128
|
/** A run of groups only: their names, so `0 tasks` says why. */
|
|
@@ -269,6 +272,11 @@ export function formatSummarySection(
|
|
|
269
272
|
const { maxMs, minMs, sumMs, count } = stats.spread
|
|
270
273
|
spread = ` ${dim(`\u00b7 max ${formatElapsed(maxMs)} \u00b7 avg ${formatElapsed(sumMs / count)} \u00b7 min ${formatElapsed(minMs)}`)}`
|
|
271
274
|
}
|
|
275
|
+
// The live run's forecast leads the spread: the one number on the row
|
|
276
|
+
// that is about the future.
|
|
277
|
+
if (stats.etaMs !== undefined) {
|
|
278
|
+
spread = ` ${dim('\u00b7')} ${paint(ACCENT, `${formatEta(stats.etaMs)} left`, colors)}${spread}`
|
|
279
|
+
}
|
|
272
280
|
// Run-shape footer (final summary only): worker pool + cache mode,
|
|
273
281
|
// grouped with time under a blank line below the meters.
|
|
274
282
|
if (context !== undefined) {
|
|
@@ -296,6 +304,17 @@ export function formatSummarySection(
|
|
|
296
304
|
return lines
|
|
297
305
|
}
|
|
298
306
|
|
|
307
|
+
/**
|
|
308
|
+
* A forecast, said as coarsely as it is known: `~4s`, `~2m 05s`, `<1s`
|
|
309
|
+
* once the history says the end is due.
|
|
310
|
+
*/
|
|
311
|
+
function formatEta(ms: number): string {
|
|
312
|
+
if (ms < 1000) return '<1s'
|
|
313
|
+
const s = Math.ceil(ms / 1000)
|
|
314
|
+
if (s < 60) return `~${s}s`
|
|
315
|
+
return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
|
|
316
|
+
}
|
|
317
|
+
|
|
299
318
|
/**
|
|
300
319
|
* `42 tasks · 38 cached (90%) · 3.2s`, `2 tasks · all cached · 23ms`,
|
|
301
320
|
* with the failures first when there are any. Cached is every hit (up to
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Zero-width escapes some terminals act on: a progress bar on the tab or
|
|
2
|
+
// taskbar (OSC 9;4) and a desktop notification (OSC 9). Sent only to a
|
|
3
|
+
// terminal known to read them: elsewhere OSC 9 IS the notification, so a
|
|
4
|
+
// progress update would post "4;1;40" as one.
|
|
5
|
+
|
|
6
|
+
export interface TerminalSignals {
|
|
7
|
+
progress: boolean
|
|
8
|
+
notify: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** What the terminal `env` names can show; tmux and screen hide it, so they get neither. */
|
|
12
|
+
export function terminalSignals(
|
|
13
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
14
|
+
): TerminalSignals {
|
|
15
|
+
const program = env['TERM_PROGRAM']
|
|
16
|
+
if (program === 'ghostty') return { progress: true, notify: true }
|
|
17
|
+
// iTerm2 posts OSC 9 since 3.0 and draws OSC 9;4 since 3.6.
|
|
18
|
+
if (program === 'iTerm.app')
|
|
19
|
+
return { progress: atLeast(env['TERM_PROGRAM_VERSION'], 3, 6), notify: true }
|
|
20
|
+
if (program === 'WezTerm') return { progress: false, notify: true }
|
|
21
|
+
// Windows Terminal and ConEmu draw the progress; neither posts OSC 9.
|
|
22
|
+
if (env['WT_SESSION'] !== undefined || env['ConEmuPID'] !== undefined)
|
|
23
|
+
return { progress: true, notify: false }
|
|
24
|
+
return { progress: false, notify: false }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function atLeast(version: string | undefined, major: number, minor: number): boolean {
|
|
28
|
+
const m = /^(\d+)\.(\d+)/.exec(version ?? '')
|
|
29
|
+
if (m === null) return false
|
|
30
|
+
const [a, b] = [Number(m[1]), Number(m[2])]
|
|
31
|
+
return a > major || (a === major && b >= minor)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Tab / taskbar progress at `pct` (0–100), or `error` to draw it red. */
|
|
35
|
+
export function progressEscape(pct: number, error = false): string {
|
|
36
|
+
return `\x1b]9;4;${error ? 2 : 1};${Math.max(0, Math.min(100, Math.round(pct)))}\x07`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const PROGRESS_CLEAR = '\x1b]9;4;0;0\x07'
|
|
40
|
+
|
|
41
|
+
/** A desktop notification. BEL and ESC end the sequence, so neither may ride in `text`. */
|
|
42
|
+
export function notifyEscape(text: string): string {
|
|
43
|
+
// eslint-disable-next-line no-control-regex -- the control range is the point
|
|
44
|
+
return `\x1b]9;${text.replace(/[\x00-\x1f\x7f]/g, ' ')}\x07`
|
|
45
|
+
}
|