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