@vzn/vx 0.0.514 → 0.0.516
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 +144 -26
- package/src/cache/layer.ts +5 -2
- package/src/cli/cache.ts +1 -1
- package/src/graph/scheduler.ts +5 -0
- package/src/orchestrator/doctor.ts +3 -3
- package/src/orchestrator/events.ts +1 -2
- package/src/orchestrator/framed-output.ts +32 -12
- package/src/orchestrator/local-shortcircuit.ts +10 -3
- package/src/orchestrator/logger.ts +22 -9
- package/src/orchestrator/run-artifacts.ts +1 -1
- package/src/orchestrator/run.ts +29 -14
- package/src/orchestrator/summary.ts +0 -79
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.516",
|
|
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.516",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.516",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.516",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.516"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
closeSync,
|
|
38
38
|
constants,
|
|
39
39
|
existsSync,
|
|
40
|
+
linkSync,
|
|
40
41
|
lstatSync,
|
|
41
42
|
mkdirSync,
|
|
42
43
|
openSync,
|
|
@@ -181,17 +182,23 @@ export function noteSchemaReset(cache: Cache, warn: (message: string) => void):
|
|
|
181
182
|
`[vx] cache entries now live in the shared store ${to}; the ones in ${from} miss once, and \`vx cache prune\` reclaims their artifacts`,
|
|
182
183
|
)
|
|
183
184
|
}
|
|
185
|
+
if (cache.storeReset !== null) {
|
|
186
|
+
const { from, to } = cache.storeReset
|
|
187
|
+
warn(
|
|
188
|
+
`[vx] shared cache store ${cache.storeDir} re-indexed: schema ${from} → ${to} (vx version changed); its artifacts stay and are indexed again as tasks hit them`,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
184
191
|
if (cache.formatChange !== null) {
|
|
185
192
|
const { from, to } = cache.formatChange
|
|
186
193
|
warn(
|
|
187
|
-
`[vx] cache format changed: ${from} → ${to} (vx
|
|
194
|
+
`[vx] cache format changed: ${from} → ${to} (vx version changed); every cached task misses once and re-saves, and the old entries, never read again, age out under \`vx cache prune --older-than\` or \`cacheRetention\``,
|
|
188
195
|
)
|
|
189
196
|
return
|
|
190
197
|
}
|
|
191
198
|
if (cache.schemaReset === null) return
|
|
192
199
|
const { from, to } = cache.schemaReset
|
|
193
200
|
warn(
|
|
194
|
-
`[vx] cache index reset: schema ${from} → ${to} (vx
|
|
201
|
+
`[vx] cache index reset: schema ${from} → ${to} (vx version changed); run history starts over, and each artifact is indexed again when its task next hits`,
|
|
195
202
|
)
|
|
196
203
|
}
|
|
197
204
|
// SCHEMA history (drop+recreate on mismatch; pre-alpha, no migrations):
|
|
@@ -269,12 +276,6 @@ const STORE_TABLES = ['entry_inputs', 'output_files', 'entry_stdout', 'store_met
|
|
|
269
276
|
const SELECT_ENTRY =
|
|
270
277
|
"SELECT e.*, COALESCE(s.stdout, '') AS stdout FROM entries e LEFT JOIN entry_stdout s ON s.hash = e.hash"
|
|
271
278
|
|
|
272
|
-
/** A schema version's number (`v28` → 28); one that is not `v<n>` is older than any. */
|
|
273
|
-
function schemaOrdinal(version: string): number {
|
|
274
|
-
const m = /^v(\d+)$/.exec(version)
|
|
275
|
-
return m === null ? -1 : Number(m[1])
|
|
276
|
-
}
|
|
277
|
-
|
|
278
279
|
/**
|
|
279
280
|
* Artifacts `indexed` does not hold, and temps, past the in-flight grace
|
|
280
281
|
* window: one readdir, one stat per candidate. `indexed` is asked only
|
|
@@ -574,6 +575,13 @@ export class Cache implements CacheLayer {
|
|
|
574
575
|
*/
|
|
575
576
|
readonly formatChange: SchemaReset | null = null
|
|
576
577
|
|
|
578
|
+
/**
|
|
579
|
+
* Set when THIS open found the shared store written under another
|
|
580
|
+
* `SCHEMA_VERSION` and dropped its inventory; the artifacts stay and are
|
|
581
|
+
* indexed again as they hit. Reported by `noteSchemaReset`.
|
|
582
|
+
*/
|
|
583
|
+
readonly storeReset: SchemaReset | null = null
|
|
584
|
+
|
|
577
585
|
/** A reading verb's open: it never resets the index (the constructor's `mode`). */
|
|
578
586
|
static inspect(cacheDir: string): Cache {
|
|
579
587
|
return new Cache(cacheDir, undefined, undefined, undefined, 'inspect')
|
|
@@ -607,7 +615,7 @@ export class Cache implements CacheLayer {
|
|
|
607
615
|
} catch {
|
|
608
616
|
return null
|
|
609
617
|
}
|
|
610
|
-
if (found === undefined ||
|
|
618
|
+
if (found === undefined || found === SCHEMA_VERSION) return null
|
|
611
619
|
const aged = await scanOrphanFiles(cacheDir, () => new Set())
|
|
612
620
|
let orphanBytes = 0
|
|
613
621
|
for (const o of aged) orphanBytes += o.size
|
|
@@ -634,7 +642,7 @@ export class Cache implements CacheLayer {
|
|
|
634
642
|
/**
|
|
635
643
|
* The shared store's directory, unversioned: every key is seeded with
|
|
636
644
|
* `CACHE_VERSION`, so two vx versions never read each other's entries,
|
|
637
|
-
* and
|
|
645
|
+
* and the store's own schema is `store_meta.schema` (`matchStoreSchema`). Or
|
|
638
646
|
* `null` for an index that holds its entries itself (a `cacheDir`).
|
|
639
647
|
* Undefined follows the layout the index records: a reading verb, a
|
|
640
648
|
* plugin's handle.
|
|
@@ -744,20 +752,14 @@ export class Cache implements CacheLayer {
|
|
|
744
752
|
| { value: string }
|
|
745
753
|
| undefined
|
|
746
754
|
)?.value
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
// among them (item 896).
|
|
755
|
+
// Any other schema is reset by an opener that may write it: the index is
|
|
756
|
+
// an inventory the artifacts rebuild, whichever vx wrote it (owner,
|
|
757
|
+
// 2026-10-06; a newer one used to be refused, item 896). A reading verb
|
|
758
|
+
// leaves it as it was.
|
|
752
759
|
const refuseUnreadable = (found: string): void => {
|
|
753
|
-
if (schemaOrdinal(found) > schemaOrdinal(SCHEMA_VERSION)) {
|
|
754
|
-
throw new UserError(
|
|
755
|
-
`the cache at ${cacheDir} holds index schema ${found}, written by a newer vx; this vx reads ${SCHEMA_VERSION} and leaves it untouched. Run the newer vx, or give this one another --cache-dir`,
|
|
756
|
-
)
|
|
757
|
-
}
|
|
758
760
|
if (mode === 'inspect') {
|
|
759
761
|
throw new UserError(
|
|
760
|
-
`the cache at ${cacheDir} holds index schema ${found} from
|
|
762
|
+
`the cache at ${cacheDir} holds index schema ${found} from another vx version; this vx reads ${SCHEMA_VERSION}, so nothing in it is readable here, and this verb leaves it untouched. The next \`vx run\` resets it`,
|
|
761
763
|
)
|
|
762
764
|
}
|
|
763
765
|
}
|
|
@@ -850,9 +852,19 @@ export class Cache implements CacheLayer {
|
|
|
850
852
|
}
|
|
851
853
|
this.storeDir = storeDir
|
|
852
854
|
this.artifactDir = storeDir ?? cacheDir
|
|
853
|
-
if (storeDir !== undefined)
|
|
855
|
+
if (storeDir !== undefined) {
|
|
856
|
+
this.attachStore(storeDir, mode === 'inspect')
|
|
857
|
+
this.storeReset = this.matchStoreSchema(mode === 'open' && this.writeBlocked === null)
|
|
858
|
+
}
|
|
854
859
|
|
|
855
860
|
createTables(this.db, storeDir === undefined ? 'main' : 'store')
|
|
861
|
+
if (storeDir !== undefined && mode === 'open' && this.writeBlocked === null) {
|
|
862
|
+
this.db
|
|
863
|
+
.prepare(
|
|
864
|
+
"INSERT INTO store.store_meta(key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
865
|
+
)
|
|
866
|
+
.run(SCHEMA_VERSION)
|
|
867
|
+
}
|
|
856
868
|
if (mode === 'open' && this.writeBlocked === null && storeDir !== recorded) {
|
|
857
869
|
if (storeDir === undefined)
|
|
858
870
|
this.db.prepare("DELETE FROM schema_meta WHERE key = 'store_dir'").run()
|
|
@@ -977,6 +989,57 @@ export class Cache implements CacheLayer {
|
|
|
977
989
|
}
|
|
978
990
|
}
|
|
979
991
|
|
|
992
|
+
/**
|
|
993
|
+
* The store is an inventory of the artifacts beside it, and only this vx
|
|
994
|
+
* reads it: a store another `SCHEMA_VERSION` wrote has its tables dropped
|
|
995
|
+
* (owner, 2026-10-06), the artifacts kept. Each is indexed again from its
|
|
996
|
+
* own bytes on its next hit (`adopt`). A reading verb reads such a store
|
|
997
|
+
* as empty and changes nothing.
|
|
998
|
+
*/
|
|
999
|
+
private matchStoreSchema(writable: boolean): SchemaReset | null {
|
|
1000
|
+
const tables = new Set(
|
|
1001
|
+
(
|
|
1002
|
+
this.db
|
|
1003
|
+
.prepare("SELECT name FROM store.sqlite_master WHERE type = 'table'")
|
|
1004
|
+
.all() as Array<{
|
|
1005
|
+
name: string
|
|
1006
|
+
}>
|
|
1007
|
+
).map((r) => r.name),
|
|
1008
|
+
)
|
|
1009
|
+
if (!tables.has('entries')) return null
|
|
1010
|
+
const found = tables.has('store_meta')
|
|
1011
|
+
? (
|
|
1012
|
+
this.db.prepare("SELECT value FROM store.store_meta WHERE key = 'schema'").get() as {
|
|
1013
|
+
value: string
|
|
1014
|
+
} | null
|
|
1015
|
+
)?.value
|
|
1016
|
+
: undefined
|
|
1017
|
+
if (found === SCHEMA_VERSION) return null
|
|
1018
|
+
if (!writable) {
|
|
1019
|
+
this.db.exec('DETACH DATABASE store')
|
|
1020
|
+
this.db.prepare('ATTACH DATABASE ? AS store').run(':memory:')
|
|
1021
|
+
return null
|
|
1022
|
+
}
|
|
1023
|
+
let reset: SchemaReset | null = null
|
|
1024
|
+
this.db
|
|
1025
|
+
.transaction(() => {
|
|
1026
|
+
// Re-read under the write lock: another workspace's open may have
|
|
1027
|
+
// reset it first.
|
|
1028
|
+
const now = (
|
|
1029
|
+
this.db.prepare("SELECT value FROM store.store_meta WHERE key = 'schema'").get() as {
|
|
1030
|
+
value: string
|
|
1031
|
+
} | null
|
|
1032
|
+
)?.value
|
|
1033
|
+
if (now === SCHEMA_VERSION) return
|
|
1034
|
+
for (const t of STORE_TABLES) {
|
|
1035
|
+
if (t !== 'store_meta') this.db.exec(`DROP TABLE IF EXISTS store.${t}`)
|
|
1036
|
+
}
|
|
1037
|
+
reset = { from: found ?? 'an earlier schema', to: SCHEMA_VERSION }
|
|
1038
|
+
})
|
|
1039
|
+
.immediate()
|
|
1040
|
+
return reset
|
|
1041
|
+
}
|
|
1042
|
+
|
|
980
1043
|
// --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
|
|
981
1044
|
getConfigEval(key: string): string | null {
|
|
982
1045
|
return this.guard(() => this.configEvals.getConfigEval(key))
|
|
@@ -1107,11 +1170,53 @@ export class Cache implements CacheLayer {
|
|
|
1107
1170
|
// ctx is accepted but ignored — the local layer reads metadata from
|
|
1108
1171
|
// the entries row. It's part of the contract so LayeredCache can
|
|
1109
1172
|
// route metadata to `ingest()` on remote-hit without a separate API.
|
|
1110
|
-
async get(hash: string,
|
|
1173
|
+
async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
|
|
1111
1174
|
// Local reads disabled (e.g. `--force` / `--cache=local:w`): report a
|
|
1112
1175
|
// miss so the task re-executes. The artifact + index are untouched.
|
|
1113
1176
|
if (!this.read) return null
|
|
1114
|
-
return this.guard(() =>
|
|
1177
|
+
return this.guard(async () => {
|
|
1178
|
+
const entry = await this.readEntry(hash)
|
|
1179
|
+
if (entry !== null || ctx === undefined || !this.write) return entry
|
|
1180
|
+
return (await this.adopt(hash, ctx)) ? this.readEntry(hash) : null
|
|
1181
|
+
})
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Index an artifact the index does not know, from its own bytes: the
|
|
1186
|
+
* artifact is the record, the index only an inventory of it (owner,
|
|
1187
|
+
* 2026-10-06). A reset index (a `SCHEMA_VERSION` change, a `cache.db` or
|
|
1188
|
+
* `store.db` deleted) loses nothing a task asks for again. The artifact
|
|
1189
|
+
* is checked as an ingest checks a remote's (its recorded key, its names
|
|
1190
|
+
* against the declared outputs); one that fails is a miss, and the run
|
|
1191
|
+
* that follows saves over it.
|
|
1192
|
+
*/
|
|
1193
|
+
private async adopt(hash: string, ctx: CacheGetContext): Promise<boolean> {
|
|
1194
|
+
const finalPath = this.tarPath(hash)
|
|
1195
|
+
if (!existsSync(finalPath)) return false
|
|
1196
|
+
const tmpPath = this.tempPath(hash)
|
|
1197
|
+
try {
|
|
1198
|
+
// A second name for the same bytes: the index step renames it over
|
|
1199
|
+
// the artifact inside its write transaction, as for a save.
|
|
1200
|
+
linkSync(finalPath, tmpPath)
|
|
1201
|
+
} catch {
|
|
1202
|
+
return false
|
|
1203
|
+
}
|
|
1204
|
+
try {
|
|
1205
|
+
await this.writeArtifactAndIndex(
|
|
1206
|
+
hash,
|
|
1207
|
+
{ tmpPath },
|
|
1208
|
+
{
|
|
1209
|
+
taskId: ctx.taskId,
|
|
1210
|
+
command: ctx.command,
|
|
1211
|
+
durationMs: 0,
|
|
1212
|
+
...(ctx.outputs !== undefined ? { outputs: ctx.outputs } : {}),
|
|
1213
|
+
},
|
|
1214
|
+
)
|
|
1215
|
+
return true
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
if (err instanceof CorruptArtifactError || err instanceof ArchiveSecurityError) return false
|
|
1218
|
+
throw err
|
|
1219
|
+
}
|
|
1115
1220
|
}
|
|
1116
1221
|
|
|
1117
1222
|
/**
|
|
@@ -1160,8 +1265,21 @@ export class Cache implements CacheLayer {
|
|
|
1160
1265
|
* The up-front short-circuit probe is the caller; per-hit `get` stays for
|
|
1161
1266
|
* the lazy path.
|
|
1162
1267
|
*/
|
|
1163
|
-
async getMany(
|
|
1164
|
-
|
|
1268
|
+
async getMany(
|
|
1269
|
+
hashes: readonly string[],
|
|
1270
|
+
ctx?: (hash: string) => CacheGetContext,
|
|
1271
|
+
): Promise<Map<string, CacheEntry>> {
|
|
1272
|
+
return this.guard(async () => {
|
|
1273
|
+
const out = await this.getManyEntries(hashes)
|
|
1274
|
+
if (ctx === undefined || !this.write || out.size === hashes.length) return out
|
|
1275
|
+
// An artifact the index does not know is still a hit (`adopt`).
|
|
1276
|
+
for (const hash of hashes) {
|
|
1277
|
+
if (out.has(hash) || !(await this.adopt(hash, ctx(hash)))) continue
|
|
1278
|
+
const entry = await this.readEntry(hash)
|
|
1279
|
+
if (entry !== null) out.set(hash, entry)
|
|
1280
|
+
}
|
|
1281
|
+
return out
|
|
1282
|
+
})
|
|
1165
1283
|
}
|
|
1166
1284
|
|
|
1167
1285
|
private async getManyEntries(hashes: readonly string[]): Promise<Map<string, CacheEntry>> {
|
package/src/cache/layer.ts
CHANGED
|
@@ -565,9 +565,12 @@ export interface CacheLayer {
|
|
|
565
565
|
/**
|
|
566
566
|
* Optional batched `get` (same answers, fewer round trips). The
|
|
567
567
|
* short-circuit probe uses it when a layer offers one; a layer without it
|
|
568
|
-
* is probed hash by hash.
|
|
568
|
+
* is probed hash by hash. `ctx` is each hash's `get` context.
|
|
569
569
|
*/
|
|
570
|
-
getMany?(
|
|
570
|
+
getMany?(
|
|
571
|
+
hashes: readonly string[],
|
|
572
|
+
ctx?: (hash: string) => CacheGetContext,
|
|
573
|
+
): Promise<Map<string, CacheEntry>>
|
|
571
574
|
/**
|
|
572
575
|
* Lightweight existence probe. `'local'` / `'remote'` names the layer
|
|
573
576
|
* that holds the artifact; `null` is a miss. NEVER moves bytes: no
|
package/src/cli/cache.ts
CHANGED
|
@@ -176,7 +176,7 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
|
|
|
176
176
|
const earlier = await Cache.orphansBeforeReset(dir)
|
|
177
177
|
if (earlier !== null) {
|
|
178
178
|
warnToStderr(
|
|
179
|
-
`[vx] the cache index is schema ${earlier.found} from
|
|
179
|
+
`[vx] the cache index is schema ${earlier.found} from another vx version: the prune resets it first, and every artifact past the hour's grace is then an orphan`,
|
|
180
180
|
)
|
|
181
181
|
printPruned({ evicted: 0, bytesFreed: 0, ...earlier }, true, json)
|
|
182
182
|
return 0
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -139,6 +139,11 @@ export interface TaskOutcome {
|
|
|
139
139
|
* flaky detection reads it off the outcome stream.
|
|
140
140
|
*/
|
|
141
141
|
attempts?: number
|
|
142
|
+
/**
|
|
143
|
+
* Set when this run proved the task flaky: its key holds both outcomes on
|
|
144
|
+
* record (this run's included) or it needed a retry. Its row carries it.
|
|
145
|
+
*/
|
|
146
|
+
flaky?: { passes: number; failures: number }
|
|
142
147
|
/**
|
|
143
148
|
* Count of sandbox violations captured during this task's exec (a task
|
|
144
149
|
* declaring `exec.sandbox`). Non-zero means the task touched something
|
|
@@ -245,9 +245,9 @@ async function collectWorkspaceInfo(
|
|
|
245
245
|
schemaVersion: SCHEMA_VERSION,
|
|
246
246
|
cacheEntries: stats.entryCount,
|
|
247
247
|
cacheBytes: stats.totalBytes,
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
248
|
+
// A row-less artifact is indexed again only when its task asks for its
|
|
249
|
+
// key; the rest only `vx cache prune` reclaims (after an upgrade's
|
|
250
|
+
// schema reset, most often).
|
|
251
251
|
orphans: { artifacts: orphans.orphans, bytes: orphans.orphanBytes },
|
|
252
252
|
runs24h: stats.runCountLast24h,
|
|
253
253
|
hits24h: stats.hitCountLast24h,
|
|
@@ -291,8 +291,7 @@ export function outcomeLabel(
|
|
|
291
291
|
|
|
292
292
|
/**
|
|
293
293
|
* Why a task was skipped, one copy for every surface: `blocked by lib#build`
|
|
294
|
-
* (the failed or aborted task at the root of the block,
|
|
295
|
-
* Skipped section groups by), or '' for fail-fast's skip — the run stopped,
|
|
294
|
+
* (the failed or aborted task at the root of the block), or '' for fail-fast's skip — the run stopped,
|
|
296
295
|
* nothing blocked it, and a label claiming an upstream failure would lie.
|
|
297
296
|
*/
|
|
298
297
|
export function skippedReason(blockedBy: string | undefined): string {
|
|
@@ -344,22 +344,42 @@ function formatOutcomeRow(
|
|
|
344
344
|
): string {
|
|
345
345
|
const st = statusOf(o)
|
|
346
346
|
const ca = cacheOf(o)
|
|
347
|
-
return
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
347
|
+
return (
|
|
348
|
+
formatTaskRow(
|
|
349
|
+
taskGlyph(o, colors),
|
|
350
|
+
ms,
|
|
351
|
+
st.word,
|
|
352
|
+
st.color,
|
|
353
|
+
ca.word,
|
|
354
|
+
ca.color,
|
|
355
|
+
paintedId,
|
|
356
|
+
colors,
|
|
357
|
+
) + flakyNote(o, colors)
|
|
356
358
|
)
|
|
357
359
|
}
|
|
358
360
|
|
|
361
|
+
/**
|
|
362
|
+
* The dim note a flaky task's row and frame carry (`flaky - failed 1×
|
|
363
|
+
* before`): nothing prints below the footer, so the task's own line is
|
|
364
|
+
* where the reader learns it. '' when the run did not prove it flaky.
|
|
365
|
+
*/
|
|
366
|
+
export function flakyNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): string {
|
|
367
|
+
if (o.flaky === undefined) return ''
|
|
368
|
+
const said: string[] = []
|
|
369
|
+
if (o.status === 'failed' && o.flaky.passes > 0)
|
|
370
|
+
said.push(`passed ${o.flaky.passes}\u00d7 before`)
|
|
371
|
+
if (o.status !== 'failed' && o.flaky.failures > 0) {
|
|
372
|
+
said.push(`failed ${o.flaky.failures}\u00d7 before`)
|
|
373
|
+
}
|
|
374
|
+
if ((o.attempts ?? 1) > 1) said.push(`${o.attempts} attempts`)
|
|
375
|
+
const text = said.length === 0 ? 'flaky' : `flaky - ${said.join(' \u00b7 ')}`
|
|
376
|
+
return ` ${paint('', text, colors, { dim: true })}`
|
|
377
|
+
}
|
|
378
|
+
|
|
359
379
|
/**
|
|
360
380
|
* Compact one-liner for a skipped task — it never ran (blank time). The
|
|
361
|
-
* blocker rides the row (`• blocked by lib#build`):
|
|
362
|
-
*
|
|
381
|
+
* blocker rides the row (`• blocked by lib#build`): nothing prints below
|
|
382
|
+
* the footer, so the row is where the reader learns what blocked it.
|
|
363
383
|
*/
|
|
364
384
|
export function formatTaskSkippedLine(
|
|
365
385
|
node: TaskNode,
|
|
@@ -564,7 +584,7 @@ function formatBlockFooter(o: TaskOutcome, colors: ColorSupport): string {
|
|
|
564
584
|
// formatStatusTag.
|
|
565
585
|
const dur = paint('', `(${formatDuration(o.durationMs)})`, colors, { dim: true })
|
|
566
586
|
const tag = formatStatusTag(o, colors)
|
|
567
|
-
return ` ${dur} ${tag}`
|
|
587
|
+
return ` ${dur} ${tag}${flakyNote(o, colors)}`
|
|
568
588
|
}
|
|
569
589
|
|
|
570
590
|
function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
|
|
@@ -46,6 +46,7 @@ import type { CacheEntry, CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
|
46
46
|
import type { TaskNode } from '../graph/index.js'
|
|
47
47
|
import { deriveStableKeys, workspaceInputsReach } from './stable-keys.js'
|
|
48
48
|
import type { HashCache } from './task-hash.js'
|
|
49
|
+
import { getContext } from './remote-prefetch.js'
|
|
49
50
|
|
|
50
51
|
export interface ShortCircuitArgs {
|
|
51
52
|
nodes: Map<string, TaskNode>
|
|
@@ -138,7 +139,14 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
138
139
|
if (args.cache.getMany !== undefined) {
|
|
139
140
|
try {
|
|
140
141
|
const endProbe = span('probe')
|
|
141
|
-
const
|
|
142
|
+
const byHash = new Map(candidates.map((c) => [c.hash, c.node]))
|
|
143
|
+
const hits = await args.cache.getMany(
|
|
144
|
+
candidates.map((c) => c.hash),
|
|
145
|
+
(hash) => {
|
|
146
|
+
const node = byHash.get(hash)!
|
|
147
|
+
return getContext(node, node.config.exec?.command ?? '')
|
|
148
|
+
},
|
|
149
|
+
)
|
|
142
150
|
endProbe()
|
|
143
151
|
for (const { hash, node } of candidates) {
|
|
144
152
|
const hit = hits.get(hash) ?? null
|
|
@@ -157,8 +165,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
157
165
|
while (next < candidates.length) {
|
|
158
166
|
const { hash, node } = candidates[next++]!
|
|
159
167
|
try {
|
|
160
|
-
const
|
|
161
|
-
const hit = await args.cache.get(hash, { taskId: node.id, command })
|
|
168
|
+
const hit = await args.cache.get(hash, getContext(node, node.config.exec?.command ?? ''))
|
|
162
169
|
preProbed.set(node.id, { hash, hit })
|
|
163
170
|
if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
|
|
164
171
|
} catch {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
formatTaskAbortedLine,
|
|
9
9
|
formatTaskBlock,
|
|
10
10
|
formatTaskExecutedLine,
|
|
11
|
+
flakyNote,
|
|
11
12
|
formatTaskHitLine,
|
|
12
13
|
formatTaskSkippedLine,
|
|
13
14
|
} from './framed-output.js'
|
|
@@ -431,6 +432,10 @@ export function defaultLogger(
|
|
|
431
432
|
(framesTerminal && holdsTerminal(node)) ||
|
|
432
433
|
(view.mode === 'focused' && isPrimary(node) && !isGroupTask(node) && requestedCount <= 1)
|
|
433
434
|
|
|
435
|
+
const failureRow = (node: TaskNode, outcome: TaskOutcome): string =>
|
|
436
|
+
formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)) +
|
|
437
|
+
flakyNote(outcome, colors)
|
|
438
|
+
|
|
434
439
|
return {
|
|
435
440
|
settle() {
|
|
436
441
|
// The bus delivers one `run:end`, so the CLI's last word reaches here.
|
|
@@ -697,25 +702,31 @@ export function defaultLogger(
|
|
|
697
702
|
return
|
|
698
703
|
}
|
|
699
704
|
case 'errors-only':
|
|
705
|
+
if (outcome.status === 'skipped') {
|
|
706
|
+
emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
|
|
707
|
+
return
|
|
708
|
+
}
|
|
700
709
|
if (outcome.status !== 'failed') return
|
|
701
|
-
emitLine(
|
|
710
|
+
emitLine(failureRow(node, outcome))
|
|
702
711
|
deferredFailures.push(
|
|
703
712
|
formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
|
|
704
713
|
)
|
|
705
714
|
return
|
|
706
715
|
case 'broad':
|
|
707
|
-
// News only: executed work
|
|
708
|
-
// the full frame. Hits (including up-to-date) are silent —
|
|
716
|
+
// News only: executed work and skips get a one-liner, failures
|
|
717
|
+
// get the full frame. Hits (including up-to-date) are silent —
|
|
709
718
|
// their replay buffers are deliberately dropped; the counts
|
|
710
719
|
// surface in the end-of-run summary.
|
|
711
720
|
if (outcome.status === 'failed') {
|
|
712
721
|
// ◼ row now; the full frame replays at runEnd.
|
|
713
|
-
emitLine(
|
|
722
|
+
emitLine(failureRow(node, outcome))
|
|
714
723
|
deferredFailures.push(
|
|
715
724
|
formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
|
|
716
725
|
)
|
|
717
726
|
} else if (outcome.status === 'success') {
|
|
718
727
|
emitLine(formatTaskExecutedLine(node, outcome, colors))
|
|
728
|
+
} else if (outcome.status === 'skipped') {
|
|
729
|
+
emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
|
|
719
730
|
}
|
|
720
731
|
return
|
|
721
732
|
case 'focused':
|
|
@@ -745,7 +756,7 @@ export function defaultLogger(
|
|
|
745
756
|
// to runEnd like everywhere else; everything else emits
|
|
746
757
|
// ONE atomic block from the buffered output.
|
|
747
758
|
if (outcome.status === 'failed') {
|
|
748
|
-
emitLine(
|
|
759
|
+
emitLine(failureRow(node, outcome))
|
|
749
760
|
deferredFailures.push(
|
|
750
761
|
formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
|
|
751
762
|
)
|
|
@@ -756,10 +767,12 @@ export function defaultLogger(
|
|
|
756
767
|
emitBlock(formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors, true))
|
|
757
768
|
return
|
|
758
769
|
}
|
|
759
|
-
// Dependency-pulled nodes: silent on success;
|
|
760
|
-
// the ◼ row now and their frame replayed at runEnd.
|
|
761
|
-
if (outcome.status === '
|
|
762
|
-
emitLine(
|
|
770
|
+
// Dependency-pulled nodes: silent on success; a skip gets its
|
|
771
|
+
// row, failures the ◼ row now and their frame replayed at runEnd.
|
|
772
|
+
if (outcome.status === 'skipped') {
|
|
773
|
+
emitLine(formatTaskSkippedLine(node, colors, outcome.blockedBy))
|
|
774
|
+
} else if (outcome.status === 'failed') {
|
|
775
|
+
emitLine(failureRow(node, outcome))
|
|
763
776
|
deferredFailures.push(
|
|
764
777
|
formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
|
|
765
778
|
)
|
|
@@ -33,7 +33,7 @@ export interface SummarizeArgs {
|
|
|
33
33
|
/** The process's exit code where it is not `ok ? 0 : 1`: a stopping signal's. */
|
|
34
34
|
exitCode?: number
|
|
35
35
|
outcomes: readonly TaskOutcome[]
|
|
36
|
-
/** What `detectFlaky` found this run;
|
|
36
|
+
/** What `detectFlaky` found this run; each row's flaky note, typed. */
|
|
37
37
|
flaky?: readonly FlakyFinding[]
|
|
38
38
|
}
|
|
39
39
|
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -90,8 +90,8 @@ import { assembleRunRecords } from './run-records.js'
|
|
|
90
90
|
import { hasEnded, selectKeepAlive, shutdownPersistent } from './persistent.js'
|
|
91
91
|
import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
|
|
92
92
|
import { createSaveLane } from './save-lane.js'
|
|
93
|
-
import {
|
|
94
|
-
import { detectFlaky, type FlakyCandidate } from './failure-mode.js'
|
|
93
|
+
import { formatRunSummary } from './summary.js'
|
|
94
|
+
import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
|
|
95
95
|
import type { RunOptions, RunSummary } from './options.js'
|
|
96
96
|
|
|
97
97
|
// Per run, never shared: a `vx watch` process runs many, and a shared map
|
|
@@ -973,6 +973,24 @@ async function runOnBus(
|
|
|
973
973
|
})
|
|
974
974
|
|
|
975
975
|
mark('classify + probe')
|
|
976
|
+
// Judged as each task finishes, against the history BEFORE this run's
|
|
977
|
+
// rows land, so the row that prints next carries the verdict: nothing
|
|
978
|
+
// prints below the footer. A hit or a skip asks nothing; a green miss
|
|
979
|
+
// is one probe of the failed-row index.
|
|
980
|
+
const flaky: FlakyFinding[] = []
|
|
981
|
+
const historyDb = prepared.localCache.dbHandle()
|
|
982
|
+
const judgeFlaky = (o: TaskOutcome): void => {
|
|
983
|
+
const candidates = flakyCandidates([o])
|
|
984
|
+
if (candidates.length === 0) return
|
|
985
|
+
try {
|
|
986
|
+
const found = detectFlaky(historyDb, candidates)[0]
|
|
987
|
+
if (found === undefined) return
|
|
988
|
+
flaky.push(found)
|
|
989
|
+
o.flaky = { passes: found.passes, failures: found.failures }
|
|
990
|
+
} catch {
|
|
991
|
+
// History is observability: an unreadable one judges nothing.
|
|
992
|
+
}
|
|
993
|
+
}
|
|
976
994
|
const outcomes = await runGraph({
|
|
977
995
|
nodes,
|
|
978
996
|
concurrency,
|
|
@@ -988,6 +1006,7 @@ async function runOnBus(
|
|
|
988
1006
|
},
|
|
989
1007
|
onFinish: (o) => {
|
|
990
1008
|
taint.settled(o)
|
|
1009
|
+
judgeFlaky(o)
|
|
991
1010
|
log.taskComplete(o.node, o)
|
|
992
1011
|
narrowDemand(o.node.id)
|
|
993
1012
|
},
|
|
@@ -1069,9 +1088,8 @@ async function runOnBus(
|
|
|
1069
1088
|
if (stillUp.length > 0) {
|
|
1070
1089
|
for (const line of formatPersistentList(stillUp, colors)) log.status(line)
|
|
1071
1090
|
}
|
|
1072
|
-
for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
|
|
1073
1091
|
// A dependency-only server that died before the end of the graph
|
|
1074
|
-
// stopped it: the footer counts it failed, this says why.
|
|
1092
|
+
// stopped it: the footer below counts it failed, this says why.
|
|
1075
1093
|
// A signal death is named as its exit code, as the keep-alive wait
|
|
1076
1094
|
// names it: `code SIGTERM` read beside `code 143` for one event (item
|
|
1077
1095
|
// 1102).
|
|
@@ -1079,20 +1097,11 @@ async function runOnBus(
|
|
|
1079
1097
|
const code = typeof c.code === 'number' ? c.code : signalExitCode(c.code)
|
|
1080
1098
|
log.status(`vx: ${c.id} exited with code ${code} before the run stopped it`)
|
|
1081
1099
|
}
|
|
1082
|
-
// The footer's "N skipped" names no task; this names each under the
|
|
1083
|
-
// failure that blocked it.
|
|
1084
|
-
for (const line of formatSkippedSection(list)) log.status(line)
|
|
1085
|
-
// Judged against the history BEFORE this run's rows land, so the query
|
|
1086
|
-
// is one scan over the executed tasks' keys and nothing at all on a run
|
|
1087
|
-
// that executed none (every hit, every skip).
|
|
1088
|
-
const flaky = detectFlaky(prepared.localCache.dbHandle(), flakyCandidates(list))
|
|
1089
|
-
for (const line of formatFlakySection(flaky)) log.status(line)
|
|
1090
1100
|
// Outputs that never came home are not an error, but a silent `dist/`
|
|
1091
1101
|
// that is empty-or-stale would be: name every task whose bytes are
|
|
1092
1102
|
// still remote.
|
|
1093
1103
|
const stillDeferred = deferredOutputs.pending()
|
|
1094
1104
|
if (stillDeferred.length > 0) {
|
|
1095
|
-
log.status('')
|
|
1096
1105
|
log.status(
|
|
1097
1106
|
` Deferred: ${stillDeferred.length} task(s) left outputs remote (--download=none): ${stillDeferred.join(', ')}`,
|
|
1098
1107
|
)
|
|
@@ -1104,6 +1113,9 @@ async function runOnBus(
|
|
|
1104
1113
|
// Ctrl-C there is the process's exit, and the first write said ok.
|
|
1105
1114
|
const summarize = async (runOk: boolean, final = list): Promise<void> => {
|
|
1106
1115
|
if (options.summarize === undefined) return
|
|
1116
|
+
// The rewrite after the keep-alive wait says nothing: the footer is
|
|
1117
|
+
// the run's last word.
|
|
1118
|
+
const quiet = final !== list
|
|
1107
1119
|
try {
|
|
1108
1120
|
const wrote = await writeRunSummary({
|
|
1109
1121
|
target: options.summarize,
|
|
@@ -1118,7 +1130,7 @@ async function runOnBus(
|
|
|
1118
1130
|
outcomes: final,
|
|
1119
1131
|
flaky,
|
|
1120
1132
|
})
|
|
1121
|
-
log.status(`vx: summary written to ${wrote}`)
|
|
1133
|
+
if (!quiet) log.status(`vx: summary written to ${wrote}`)
|
|
1122
1134
|
} catch (err) {
|
|
1123
1135
|
const msg = err instanceof Error ? err.message : String(err)
|
|
1124
1136
|
log.status(`vx: failed to write summary: ${msg}`)
|
|
@@ -1138,6 +1150,9 @@ async function runOnBus(
|
|
|
1138
1150
|
log.status(`vx: failed to write profile: ${msg}`)
|
|
1139
1151
|
}
|
|
1140
1152
|
}
|
|
1153
|
+
// The footer is the run's last word: every line above, nothing below
|
|
1154
|
+
// (owner, 2026-10-06). A task's own facts (flaky, blocked) ride its row.
|
|
1155
|
+
for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
|
|
1141
1156
|
|
|
1142
1157
|
// Record each task to the run history in a single SQLite transaction
|
|
1143
1158
|
// (one fsync instead of N), with the invocation header row alongside,
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// aren't real work and would inflate "N total" misleadingly.
|
|
5
5
|
|
|
6
6
|
import type { TaskOutcome } from '../graph/index.js'
|
|
7
|
-
import type { FlakyFinding } from './failure-mode.js'
|
|
8
7
|
import { paint, type ColorSupport } from './colors.js'
|
|
9
8
|
import { tallyOutcomes } from './tally.js'
|
|
10
9
|
import { isGroupTask } from '../graph/index.js'
|
|
@@ -386,84 +385,6 @@ export function neverStarted(o: TaskOutcome): boolean {
|
|
|
386
385
|
return o.status === 'aborted' && o.wallclockStartNs === undefined
|
|
387
386
|
}
|
|
388
387
|
|
|
389
|
-
/**
|
|
390
|
-
* Post-summary section naming the tasks that never started, under the
|
|
391
|
-
* failure that blocked them. The footer's "1 skipped" says a task was
|
|
392
|
-
* blocked and nothing about which task or by what; the broad flow prints
|
|
393
|
-
* no row for a skipped task on purpose (a wide red run would drown in
|
|
394
|
-
* them), so this is where the reader learns what the failure cost
|
|
395
|
-
* (item 266). The scheduler records the root of each block on the outcome
|
|
396
|
-
* (`blockedBy`, item 267); a skip without one is fail-fast's. Empty when
|
|
397
|
-
* nothing was skipped.
|
|
398
|
-
*/
|
|
399
|
-
export function formatSkippedSection(outcomes: readonly TaskOutcome[]): string[] {
|
|
400
|
-
// A group (no `exec`) never starts by definition and is not a task the
|
|
401
|
-
// tally counts; listing a blocked one here named three "tasks" the legend
|
|
402
|
-
// beside it counted as none (item 281). Its members' rows say enough.
|
|
403
|
-
const skipped = outcomes.filter((o) => o.status === 'skipped' && !isGroupTask(o.node))
|
|
404
|
-
if (skipped.length === 0) return []
|
|
405
|
-
const byId = new Map(outcomes.map((o) => [o.node.id, o]))
|
|
406
|
-
const causeOf = (o: TaskOutcome): string => {
|
|
407
|
-
if (o.blockedBy === undefined) return 'after the run stopped (fail-fast)'
|
|
408
|
-
const root = byId.get(o.blockedBy)
|
|
409
|
-
return root?.status === 'aborted'
|
|
410
|
-
? `after ${o.blockedBy} was aborted`
|
|
411
|
-
: `after ${o.blockedBy} failed`
|
|
412
|
-
}
|
|
413
|
-
const groups = new Map<string, string[]>()
|
|
414
|
-
for (const o of skipped) {
|
|
415
|
-
const cause = causeOf(o)
|
|
416
|
-
const list = groups.get(cause) ?? []
|
|
417
|
-
list.push(o.node.id)
|
|
418
|
-
groups.set(cause, list)
|
|
419
|
-
}
|
|
420
|
-
// "Blocked upstream" only when every skip was: a fail-fast skip had no
|
|
421
|
-
// blocker, and the header claimed one over its own cause line (C-55).
|
|
422
|
-
const blocked = skipped.every((o) => o.blockedBy !== undefined) ? ' \u2014 blocked upstream' : ''
|
|
423
|
-
const lines = [
|
|
424
|
-
'',
|
|
425
|
-
` Skipped: ${skipped.length} task${skipped.length === 1 ? '' : 's'} never started${blocked}`,
|
|
426
|
-
]
|
|
427
|
-
const NAMES = 8
|
|
428
|
-
for (const [cause, ids] of [...groups].sort((a, b) => b[1].length - a[1].length)) {
|
|
429
|
-
ids.sort()
|
|
430
|
-
const shown = ids.slice(0, NAMES).join(', ')
|
|
431
|
-
const more = ids.length > NAMES ? ` \u2026 +${ids.length - NAMES} more` : ''
|
|
432
|
-
lines.push(` \u2298 ${cause}: ${shown}${more}`)
|
|
433
|
-
}
|
|
434
|
-
return lines
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* Post-summary section naming the tasks this run proved flaky: identical
|
|
439
|
-
* inputs, both outcomes on record (or a retry within the run). A red run
|
|
440
|
-
* whose failure has passed on these exact inputs before is not a break to
|
|
441
|
-
* bisect, and a green run that only passed on the second attempt is not
|
|
442
|
-
* green — either way the footer's counts alone would say nothing. Empty
|
|
443
|
-
* when nothing was.
|
|
444
|
-
*/
|
|
445
|
-
export function formatFlakySection(findings: readonly FlakyFinding[]): string[] {
|
|
446
|
-
if (findings.length === 0) return []
|
|
447
|
-
const lines = [
|
|
448
|
-
'',
|
|
449
|
-
` Flaky: ${findings.length} task${findings.length === 1 ? '' : 's'} with the same inputs both passing and failing on record`,
|
|
450
|
-
]
|
|
451
|
-
for (const f of findings) {
|
|
452
|
-
const times = (n: number): string => `${n}\u00d7`
|
|
453
|
-
const history =
|
|
454
|
-
f.status === 'failed'
|
|
455
|
-
? `failed on inputs that passed ${times(f.passes)} before`
|
|
456
|
-
: f.failures > 0
|
|
457
|
-
? `passed on inputs that failed ${times(f.failures)} before`
|
|
458
|
-
: 'passed'
|
|
459
|
-
const retried = f.attempts > 1 ? ` \u00b7 ${f.attempts} attempts this run` : ''
|
|
460
|
-
lines.push(
|
|
461
|
-
` ${f.status === 'failed' ? '\u2717' : '\u2713'} ${f.taskId} \u2014 ${history}${retried}`,
|
|
462
|
-
)
|
|
463
|
-
}
|
|
464
|
-
return lines
|
|
465
|
-
}
|
|
466
|
-
|
|
467
388
|
export function formatDuration(ms: number): string {
|
|
468
389
|
return formatElapsed(ms)
|
|
469
390
|
}
|