@vzn/vx 0.0.637 → 0.0.638
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/docs/caching.md +41 -14
- package/docs/cli.md +115 -73
- package/docs/execution.md +1 -1
- package/docs/features.md +6 -5
- package/docs/schema.md +24 -15
- package/package.json +5 -5
- package/schemas/plan.json +2 -2
- package/skills/vx/SKILL.md +13 -2
- package/src/bin.ts +13 -7
- package/src/cache/cache.ts +21 -2
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/index.ts +9 -1
- package/src/cache/inputs.ts +0 -24
- package/src/cache/layer.ts +7 -1
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +9 -0
- package/src/cache/run-history.ts +68 -0
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +38 -9
- package/src/cache/scoped-cache.ts +15 -2
- package/src/cli/cache.ts +33 -16
- package/src/cli/completions.ts +9 -5
- package/src/cli/help.ts +16 -7
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +6 -5
- package/src/cli/init.ts +14 -15
- package/src/cli/last.ts +14 -7
- package/src/cli/lock.ts +39 -27
- package/src/cli/plan-format.ts +5 -3
- package/src/cli/plugin-commands.ts +77 -6
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +14 -12
- package/src/cli/select.ts +18 -9
- package/src/cli/show.ts +66 -40
- package/src/cli/task-verb.ts +4 -4
- package/src/cli/upgrade.ts +20 -11
- package/src/cli/watch-filter.ts +7 -2
- package/src/cli/watch-judge.ts +6 -1
- package/src/cli/watch.ts +44 -20
- package/src/cli/why.ts +10 -1
- package/src/cli/workspace-config.ts +2 -2
- package/src/graph/scheduler.ts +8 -0
- package/src/graph/task-graph.ts +32 -4
- package/src/index.ts +11 -1
- package/src/orchestrator/affected-tasks.ts +8 -3
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-task.ts +26 -43
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/framed-output.ts +1 -2
- package/src/orchestrator/history.ts +60 -4
- package/src/orchestrator/index.ts +2 -1
- package/src/orchestrator/logger.ts +9 -2
- package/src/orchestrator/metrics.ts +7 -3
- package/src/orchestrator/plugin-host.ts +14 -4
- package/src/orchestrator/plugin.ts +80 -8
- package/src/orchestrator/prepare.ts +51 -7
- package/src/orchestrator/projects.ts +3 -0
- package/src/orchestrator/remote-prefetch.ts +9 -5
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run.ts +126 -27
- package/src/orchestrator/status-line.ts +1 -1
- package/src/orchestrator/summary.ts +69 -44
- package/src/orchestrator/telemetry.ts +1 -1
- package/src/settings.ts +12 -1
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +32 -0
- package/src/util/index.ts +8 -1
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/ui.ts +186 -0
- package/src/workspace/affected.ts +20 -23
- package/src/workspace/dependency-rules.ts +12 -4
- package/src/workspace/migration.ts +87 -91
- package/src/workspace/workspace.ts +5 -4
package/src/graph/task-graph.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
splitNegations,
|
|
8
8
|
staticPrefix,
|
|
9
9
|
taskGlob,
|
|
10
|
+
refusal,
|
|
10
11
|
UserError,
|
|
11
12
|
wholeSubtreePrefixes,
|
|
12
13
|
} from '../util/index.js'
|
|
@@ -969,6 +970,19 @@ function witnessed(a: string, b: string): boolean {
|
|
|
969
970
|
if (ga.match(candidate) && gb.match(candidate)) return true
|
|
970
971
|
}
|
|
971
972
|
}
|
|
973
|
+
// Each glob filled in whole, every wildcard a name and `**` one level or
|
|
974
|
+
// none: `**/dist/**` beside `**/*` meets at `x/dist/x`, a path neither
|
|
975
|
+
// last segment builds (solid, M 2026-10-15).
|
|
976
|
+
for (const glob of [a, b]) {
|
|
977
|
+
const segs = glob.split('/')
|
|
978
|
+
if (segs.some((s) => /[[\]{}()!+@]/.test(s))) continue
|
|
979
|
+
for (const drop of [false, true]) {
|
|
980
|
+
const candidate = segs
|
|
981
|
+
.flatMap((s) => (s === '**' ? (drop ? [] : ['x']) : [s.replace(/\*+|\?/g, 'x')]))
|
|
982
|
+
.join('/')
|
|
983
|
+
if (candidate !== '' && ga.match(candidate) && gb.match(candidate)) return true
|
|
984
|
+
}
|
|
985
|
+
}
|
|
972
986
|
return false
|
|
973
987
|
}
|
|
974
988
|
|
|
@@ -1407,8 +1421,19 @@ function collide(
|
|
|
1407
1421
|
// `run.rules.exclusiveOutputs` is off (X-53). Without an edge the two run
|
|
1408
1422
|
// in either order, and the refusal below stands.
|
|
1409
1423
|
const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
|
|
1424
|
+
const parts = {
|
|
1425
|
+
headline: `${a.id} and ${b.id} both write ${ga === gb ? ga : `${ga} / ${gb}`}`,
|
|
1426
|
+
...(field === 'files' ? {} : { what: ['in cache.outputs.workspaceFiles'] }),
|
|
1427
|
+
why: "vx clears a task's outputs before it runs, so one deletes the other's",
|
|
1428
|
+
}
|
|
1410
1429
|
if (up !== undefined && exclusive)
|
|
1411
|
-
throw
|
|
1430
|
+
throw refusal(
|
|
1431
|
+
{
|
|
1432
|
+
...parts,
|
|
1433
|
+
fix: 'give each task its own output path, or set run.rules.exclusiveOutputs: false in .vx/workspace.ts',
|
|
1434
|
+
},
|
|
1435
|
+
{ code: 'VX_E_OUTPUT_OVERLAP', message: `${head}. ${EXCLUSIVE_OUTPUTS_FIX}` },
|
|
1436
|
+
)
|
|
1412
1437
|
if (up !== undefined && down !== undefined) {
|
|
1413
1438
|
const downGlobs = splitNegations(
|
|
1414
1439
|
(field === 'files'
|
|
@@ -1419,9 +1444,12 @@ function collide(
|
|
|
1419
1444
|
;(up.outputsAddedToBy ??= []).push(...(downGlobs ?? []))
|
|
1420
1445
|
return
|
|
1421
1446
|
}
|
|
1422
|
-
throw
|
|
1423
|
-
|
|
1424
|
-
|
|
1447
|
+
throw refusal(
|
|
1448
|
+
{ ...parts, fix: 'give each task its own output path' },
|
|
1449
|
+
{
|
|
1450
|
+
code: 'VX_E_OUTPUT_OVERLAP',
|
|
1451
|
+
message: `${head} and the run still reports success. Give each task its own output path.`,
|
|
1452
|
+
},
|
|
1425
1453
|
)
|
|
1426
1454
|
}
|
|
1427
1455
|
}
|
package/src/index.ts
CHANGED
|
@@ -89,7 +89,13 @@ export type { TaskNode, TaskOutcome, TaskStatus } from './graph/index.js'
|
|
|
89
89
|
|
|
90
90
|
// Cache classes + the layer interface (the `cache` capability's currency).
|
|
91
91
|
export { Cache } from './cache/index.js'
|
|
92
|
-
export type {
|
|
92
|
+
export type {
|
|
93
|
+
CacheLayer,
|
|
94
|
+
PluginCache,
|
|
95
|
+
RemoteCacheLayer,
|
|
96
|
+
RunRecord,
|
|
97
|
+
InvocationRecord,
|
|
98
|
+
} from './cache/index.js'
|
|
93
99
|
|
|
94
100
|
// Workspace discovery + the project/config catalog surface — an
|
|
95
101
|
// out-of-process service/CLI needs these. `loadProjectConfig` is the RAW
|
|
@@ -118,6 +124,8 @@ export {
|
|
|
118
124
|
quoteTsLiteral,
|
|
119
125
|
taskNameProblem,
|
|
120
126
|
} from './workspace/index.js'
|
|
127
|
+
// Output components: a plugin draws its lines with core's, so they look alike.
|
|
128
|
+
export { ui } from './util/index.js'
|
|
121
129
|
export type {
|
|
122
130
|
ApplyMigrationArgs,
|
|
123
131
|
MigrationFormat,
|
|
@@ -166,6 +174,8 @@ export type {
|
|
|
166
174
|
DiscoverContext,
|
|
167
175
|
DiscoveredProject,
|
|
168
176
|
PluginSetupContext,
|
|
177
|
+
RunReport,
|
|
178
|
+
RunReportTask,
|
|
169
179
|
PluginHookName,
|
|
170
180
|
PluginHookHandlers,
|
|
171
181
|
RunStartInfo,
|
|
@@ -62,13 +62,15 @@ function wholeProjects(
|
|
|
62
62
|
* `input`, a changed `file` is one of its declared inputs; `project`, its
|
|
63
63
|
* project changed and the task reads it whole (an uncached task, a path no
|
|
64
64
|
* cached task declares, or no single `file`: a lockfile claim, a manifest
|
|
65
|
-
* edge, a config import); `
|
|
65
|
+
* edge, a config import); `workspace`, a workspace-wide `file` (the
|
|
66
|
+
* workspace config, a root file a plugin claims) reached every task of its
|
|
67
|
+
* project; `package`, a `^` edge passes through `project`, a
|
|
66
68
|
* changed package with no such task; `named`, asked as `pkg#task`;
|
|
67
69
|
* `selected`, another filter selected its project outright. `via` is the
|
|
68
70
|
* `dependsOn` chain, nearest first, to the task the change seeded.
|
|
69
71
|
*/
|
|
70
72
|
export interface AffectedReason {
|
|
71
|
-
kind: 'input' | 'project' | 'package' | 'named' | 'selected'
|
|
73
|
+
kind: 'input' | 'project' | 'workspace' | 'package' | 'named' | 'selected'
|
|
72
74
|
/** Workspace-relative. */
|
|
73
75
|
file?: string
|
|
74
76
|
project?: string
|
|
@@ -102,7 +104,10 @@ export function affectedRoots(
|
|
|
102
104
|
const seeds = explain === undefined ? null : new Map<string, AffectedReason>()
|
|
103
105
|
const through = explain === undefined ? null : new Map<string, string>()
|
|
104
106
|
const inProject = (name: string, rel: string | undefined): AffectedReason => {
|
|
105
|
-
if (rel === undefined)
|
|
107
|
+
if (rel === undefined) {
|
|
108
|
+
const file = changes.wholeBy?.get(name)
|
|
109
|
+
return file === undefined ? { kind: 'project' } : { kind: 'workspace', file }
|
|
110
|
+
}
|
|
106
111
|
const dir = path.relative(explain!.workspaceRoot, projects.get(name)!.dir)
|
|
107
112
|
return { kind: 'project', file: dir === '' ? rel : path.posix.join(dir, rel) }
|
|
108
113
|
}
|
|
@@ -384,7 +384,7 @@ function gitVersion(): string | null {
|
|
|
384
384
|
function settingsFact(config: WorkspaceConfig | null): InfoFacts['settings'] {
|
|
385
385
|
const out: InfoFacts['settings'] = {}
|
|
386
386
|
for (const s of SETTINGS) {
|
|
387
|
-
const env = process.env[envName(s.path)]
|
|
387
|
+
const env = s.env === false ? undefined : process.env[envName(s.path)]
|
|
388
388
|
const declared = s.config === false ? undefined : atPath(config, s.path)
|
|
389
389
|
out[s.path] =
|
|
390
390
|
env !== undefined && env !== ''
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
type CacheEntry,
|
|
6
6
|
type CacheLayer,
|
|
7
7
|
type CachePolicy,
|
|
8
|
-
anyUntouched,
|
|
9
8
|
cleanOutputs,
|
|
10
9
|
committedFiles,
|
|
11
10
|
cleanWorkspaceOutputs,
|
|
@@ -189,7 +188,7 @@ export interface ExecuteArgs {
|
|
|
189
188
|
* the env vars (above the task), `workspace` from `.vx/workspace` (below).
|
|
190
189
|
*/
|
|
191
190
|
inputStability?: { run: InputStability; workspace?: InputStability }
|
|
192
|
-
/** `cache.outputs.clean` for tasks without their own:
|
|
191
|
+
/** `cache.outputs.clean` for tasks without their own: `.vx/workspace`. */
|
|
193
192
|
cleanOutputs?: boolean
|
|
194
193
|
/** Per-run memo for `git ls-files` (one entry per project dir). */
|
|
195
194
|
gitFilesCache?: GitFilesCache
|
|
@@ -257,6 +256,12 @@ export interface ExecuteArgs {
|
|
|
257
256
|
* clean run's stale hit.
|
|
258
257
|
*/
|
|
259
258
|
taintedUpstream?: boolean
|
|
259
|
+
/**
|
|
260
|
+
* The run keeps its bytes in a remote (run-store.ts): an attempt captures
|
|
261
|
+
* its output at the entry's bounds, and an outcome that saves no entry
|
|
262
|
+
* (a failure, an uncached run) carries it as `runLog`.
|
|
263
|
+
*/
|
|
264
|
+
keepRunLog?: boolean
|
|
260
265
|
/**
|
|
261
266
|
* Whether a task has rewritten a root file a plugin claims (whose bytes
|
|
262
267
|
* the claimant folded) since the run read it (fingerprint-watch.ts): past that, no key taken
|
|
@@ -977,11 +982,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
977
982
|
let stampedBefore: ReadonlyMap<string, OutputStamp> | undefined
|
|
978
983
|
let wsStampedBefore: ReadonlyMap<string, OutputStamp> | undefined
|
|
979
984
|
// `cache.outputs.clean: false` leaves the outputs to the first attempt's
|
|
980
|
-
// command,
|
|
981
|
-
//
|
|
985
|
+
// command, and the save stores whatever they hold after it, a leftover
|
|
986
|
+
// included: the user opted out of the clean-tree guarantee (M,
|
|
987
|
+
// 2026-10-15). A retry still starts clean. An additive task never cleans
|
|
988
|
+
// by glob anyway.
|
|
982
989
|
const keepOutputs = !additive && (cacheCfg?.outputs.clean ?? args.cleanOutputs ?? true) === false
|
|
983
|
-
let keptBefore: ReadonlyMap<string, OutputStamp> | undefined
|
|
984
|
-
let wsKeptBefore: ReadonlyMap<string, OutputStamp> | undefined
|
|
985
990
|
|
|
986
991
|
// Cache lookup. On hit, time the user-perceived restore op
|
|
987
992
|
// (clean+restore+log-replay) — that's what the framed-block footer
|
|
@@ -1148,6 +1153,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1148
1153
|
exitCode: number
|
|
1149
1154
|
withdrawn?: true
|
|
1150
1155
|
}> {
|
|
1156
|
+
// The first attempt under `clean: false` finds its last outputs (tsc's `.tsbuildinfo`).
|
|
1157
|
+
const clean = willWrite && !deferralRequested && !(keepOutputs && attempt === 1)
|
|
1151
1158
|
// A deferred task's outputs are deliberately NOT coming, so wiping the
|
|
1152
1159
|
// tree would replace a stale build with nothing at all. The eligibility
|
|
1153
1160
|
// gate guarantees no key in this run can see what stays behind, and the
|
|
@@ -1156,16 +1163,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1156
1163
|
const endStamp = span('miss: stamp outputs')
|
|
1157
1164
|
stampedBefore ??= await stampOutputs(cleanArgs)
|
|
1158
1165
|
endStamp()
|
|
1159
|
-
} else if (
|
|
1160
|
-
keepOutputs &&
|
|
1161
|
-
attempt === 1 &&
|
|
1162
|
-
willWrite &&
|
|
1163
|
-
!deferralRequested &&
|
|
1164
|
-
outputs.length > 0
|
|
1165
|
-
) {
|
|
1166
|
-
keptBefore = await stampOutputs(cleanArgs)
|
|
1167
|
-
} else if (willWrite && !deferralRequested && outputs.length > 0) {
|
|
1168
|
-
keptBefore = undefined
|
|
1166
|
+
} else if (clean && outputs.length > 0) {
|
|
1169
1167
|
// Mark the wiped paths, exactly as the workspace twin below does. The
|
|
1170
1168
|
// git snapshot still lists them with their committed index OIDs, and
|
|
1171
1169
|
// resolveFiles SKIPS its existence probe for any path carrying a
|
|
@@ -1182,16 +1180,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1182
1180
|
// glob clean deleted the upstream's files before this task, which
|
|
1183
1181
|
// reads them, ran (A-43).
|
|
1184
1182
|
wsStampedBefore ??= await stampWorkspaceOutputs(wsCleanArgs)
|
|
1185
|
-
} else if (
|
|
1186
|
-
keepOutputs &&
|
|
1187
|
-
attempt === 1 &&
|
|
1188
|
-
willWrite &&
|
|
1189
|
-
!deferralRequested &&
|
|
1190
|
-
wsOutputs.length > 0
|
|
1191
|
-
) {
|
|
1192
|
-
wsKeptBefore = await stampWorkspaceOutputs(wsCleanArgs)
|
|
1193
|
-
} else if (willWrite && !deferralRequested && wsOutputs.length > 0) {
|
|
1194
|
-
wsKeptBefore = undefined
|
|
1183
|
+
} else if (clean && wsOutputs.length > 0) {
|
|
1195
1184
|
// Root-anchored deletions can land in other projects' dirs; mark
|
|
1196
1185
|
// them so stale per-project git snapshots can't survive the wipe.
|
|
1197
1186
|
const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
|
|
@@ -1481,9 +1470,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1481
1470
|
}
|
|
1482
1471
|
|
|
1483
1472
|
async function buildRequest(local: boolean): Promise<ExecuteRequest> {
|
|
1484
|
-
const kept =
|
|
1485
|
-
|
|
1486
|
-
|
|
1473
|
+
const kept =
|
|
1474
|
+
willSave || args.keepRunLog === true
|
|
1475
|
+
? new BoundedCapture()
|
|
1476
|
+
: new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
|
|
1487
1477
|
output = kept
|
|
1488
1478
|
const toOut = (t: string): void => {
|
|
1489
1479
|
kept.push(t)
|
|
@@ -1553,6 +1543,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1553
1543
|
const stability = inputStabilityOf(args.node.config.cache, args.inputStability)
|
|
1554
1544
|
let movedFails: string | undefined
|
|
1555
1545
|
let own: Awaited<ReturnType<typeof ownOutputs>>
|
|
1546
|
+
let saved = false
|
|
1556
1547
|
if (effectiveExitCode === 0 && willSave && deferralRequested) {
|
|
1557
1548
|
// The outputs never landed here: no artifact, no rows. A partial local
|
|
1558
1549
|
// record (a row with no artifact) is exactly the corrupt-entry shape
|
|
@@ -1580,7 +1571,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1580
1571
|
} else if (
|
|
1581
1572
|
effectiveExitCode === 0 &&
|
|
1582
1573
|
willSave &&
|
|
1583
|
-
!leftBehind() &&
|
|
1584
1574
|
(await keyStillTrue()) &&
|
|
1585
1575
|
(own = await ownOutputs()) !== undefined
|
|
1586
1576
|
) {
|
|
@@ -1617,6 +1607,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1617
1607
|
: {}),
|
|
1618
1608
|
})
|
|
1619
1609
|
args.deferredSaves?.set(node.id, landed)
|
|
1610
|
+
saved = true
|
|
1620
1611
|
} else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants !== true) {
|
|
1621
1612
|
// Ran here and saves nothing — failed, a read-only policy, a tainted
|
|
1622
1613
|
// upstream, a key that no longer held — yet it wrote what a save would
|
|
@@ -1642,18 +1633,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1642
1633
|
}
|
|
1643
1634
|
}
|
|
1644
1635
|
|
|
1645
|
-
/**
|
|
1646
|
-
* A file the kept outputs held that the run left as it was: saving would
|
|
1647
|
-
* file a leftover from an older build as this run's output, so nothing is
|
|
1648
|
-
* saved (`cache.outputs.clean: false`).
|
|
1649
|
-
*/
|
|
1650
|
-
function leftBehind(): boolean {
|
|
1651
|
-
return (
|
|
1652
|
-
(keptBefore !== undefined && anyUntouched(keptBefore)) ||
|
|
1653
|
-
(wsKeptBefore !== undefined && anyUntouched(wsKeptBefore))
|
|
1654
|
-
)
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1657
1636
|
/**
|
|
1658
1637
|
* What an additive run saves as its own, or undefined when it removed a
|
|
1659
1638
|
* file it found: no entry replays a removal (`ownOutputsSince`), so the
|
|
@@ -1771,8 +1750,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1771
1750
|
: {}),
|
|
1772
1751
|
...(inputChanges !== undefined ? { inputChanges } : {}),
|
|
1773
1752
|
...(unkeyed ? { unkeyed: true as const } : {}),
|
|
1753
|
+
...(saved ? { saved: true as const } : {}),
|
|
1774
1754
|
...(effectiveExitCode !== 0 && output !== undefined
|
|
1775
|
-
? { failedOutput: failedOutputLog(output, willSave) }
|
|
1755
|
+
? { failedOutput: failedOutputLog(output, willSave || args.keepRunLog === true) }
|
|
1756
|
+
: {}),
|
|
1757
|
+
...(args.keepRunLog === true && (effectiveExitCode !== 0 || !willSave) && output !== undefined
|
|
1758
|
+
? { runLog: storedOutput() }
|
|
1776
1759
|
: {}),
|
|
1777
1760
|
...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
|
|
1778
1761
|
...(movedFails !== undefined ? { inputMoved: movedFails } : {}),
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// drift into opposite verdicts on identical data. Both build on
|
|
5
5
|
// `mixedOutcomeKeysSql` + `failureModeOf`, so the rule cannot fork again.
|
|
6
6
|
// The per-run detector (`detectFlaky`) and the doctor's list (`flakyTasks`)
|
|
7
|
-
// read the same per-key projection (`keyOutcomesSql`)
|
|
7
|
+
// read the same per-key projection (`keyOutcomesSql`) over the shared
|
|
8
|
+
// store's flaky record (`task_outcomes`, v38), so every worktree of a repo
|
|
9
|
+
// judges a key on what all of them saw.
|
|
8
10
|
|
|
9
11
|
import type { Database } from 'bun:sqlite'
|
|
10
12
|
import { KEYED_RUNS_SQL } from '../cache/index.js'
|
|
@@ -17,6 +19,9 @@ const HIT_STATUSES = `(${TASK_STATUSES.filter(isCacheHit)
|
|
|
17
19
|
.map((s) => `'${s}'`)
|
|
18
20
|
.join(', ')})`
|
|
19
21
|
|
|
22
|
+
/** The shared store's flaky record: what `detectFlaky` and `flakyTasks` read. */
|
|
23
|
+
const OUTCOMES = 'task_outcomes'
|
|
24
|
+
|
|
20
25
|
export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
|
|
21
26
|
|
|
22
27
|
/**
|
|
@@ -33,6 +38,10 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
|
|
|
33
38
|
* projection.
|
|
34
39
|
*/
|
|
35
40
|
function keyOutcomesSql(source: string, where = ''): string {
|
|
41
|
+
// The flaky record keeps only rows that observed their key (run-history.ts
|
|
42
|
+
// `observesKey`), so it carries none of the filter's columns.
|
|
43
|
+
const keyed =
|
|
44
|
+
source === OUTCOMES ? '1' : `${KEYED_RUNS_SQL} AND cached IS NOT 0 AND blocked_by IS NULL`
|
|
36
45
|
return `SELECT project, task, hash, SUM(failed) AS failures, SUM(passed) AS passes,
|
|
37
46
|
SUM(retried) AS retried_failures,
|
|
38
47
|
SUM(CASE WHEN failed = 1 AND id > first_pass THEN 1 ELSE 0 END) AS relapses
|
|
@@ -41,10 +50,10 @@ function keyOutcomesSql(source: string, where = ''): string {
|
|
|
41
50
|
FROM (SELECT project, task, hash, id,
|
|
42
51
|
CASE WHEN status = 'failed' THEN 1 ELSE 0 END AS failed,
|
|
43
52
|
CASE WHEN status = 'success' AND attempts > 1 THEN attempts - 1 ELSE 0 END AS retried,
|
|
44
|
-
CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
|
|
53
|
+
CASE WHEN status = 'success' OR status IN ${HIT_STATUSES}${source === OUTCOMES ? '' : ' OR cache_hit = 1'}
|
|
45
54
|
THEN 1 ELSE 0 END AS passed
|
|
46
55
|
FROM ${source}
|
|
47
|
-
WHERE ${
|
|
56
|
+
WHERE ${keyed}${where}))
|
|
48
57
|
GROUP BY project, task, hash`
|
|
49
58
|
}
|
|
50
59
|
|
|
@@ -136,9 +145,9 @@ const keyOf = (c: { project: string; task: string; hash: string }): string =>
|
|
|
136
145
|
* failures is a recovery, not a flake. The counts fold this run in.
|
|
137
146
|
*
|
|
138
147
|
* The cost follows the run's colour. No candidate (every task a hit or a
|
|
139
|
-
* skip): no query. A green miss: one probe of the `
|
|
140
|
-
* index for the keys that ever failed —
|
|
141
|
-
* probe is microseconds however long the history. Only a key that DID fail
|
|
148
|
+
* skip): no query. A green miss: one probe of the `task_outcomes_hash`
|
|
149
|
+
* index for the keys that ever failed — a key holds a handful of rows, so
|
|
150
|
+
* the probe is microseconds however long the history. Only a key that DID fail
|
|
142
151
|
* before, or a task failing now, pays the projection scan over its own
|
|
143
152
|
* keys, and a run that is failing has more to look at than a scan.
|
|
144
153
|
*/
|
|
@@ -149,8 +158,8 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
|
|
|
149
158
|
chunked(passedNow, (chunk) => {
|
|
150
159
|
const rows = db
|
|
151
160
|
.query(
|
|
152
|
-
`SELECT DISTINCT project, task, hash FROM
|
|
153
|
-
WHERE status = 'failed' AND
|
|
161
|
+
`SELECT DISTINCT project, task, hash FROM ${OUTCOMES}
|
|
162
|
+
WHERE status = 'failed' AND hash IN (${chunk.map(() => '?').join(', ')})`,
|
|
154
163
|
)
|
|
155
164
|
.all(...chunk.map((c) => c.hash)) as { project: string; task: string; hash: string }[]
|
|
156
165
|
for (const r of rows) everFailed.add(keyOf(r))
|
|
@@ -162,7 +171,7 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
|
|
|
162
171
|
const counts = new Map<string, { passes: number; failures: number; relapses: number }>()
|
|
163
172
|
chunked(suspects, (chunk) => {
|
|
164
173
|
const rows = db
|
|
165
|
-
.query(keyOutcomesSql(
|
|
174
|
+
.query(keyOutcomesSql(OUTCOMES, ` AND hash IN (${chunk.map(() => '?').join(', ')})`))
|
|
166
175
|
.all(...chunk.map((c) => c.hash)) as {
|
|
167
176
|
project: string
|
|
168
177
|
task: string
|
|
@@ -212,7 +221,7 @@ export function flakyTasks(db: Database): FlakyTask[] {
|
|
|
212
221
|
.query(
|
|
213
222
|
`SELECT project, task, COUNT(*) AS keys, SUM(passes) AS passes, SUM(failures) AS failures
|
|
214
223
|
FROM (SELECT project, task, passes, failures + retried_failures AS failures
|
|
215
|
-
FROM (${keyOutcomesSql(
|
|
224
|
+
FROM (${keyOutcomesSql(OUTCOMES)})
|
|
216
225
|
WHERE relapses > 0 OR retried_failures > 0)
|
|
217
226
|
GROUP BY project, task
|
|
218
227
|
ORDER BY failures DESC, passes DESC, project, task`,
|
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { withForwardArgs } from '../exec/index.js'
|
|
29
29
|
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
30
|
-
import { formatElapsed, maskedCommand } from '../util/index.js'
|
|
31
|
-
import { paint, type ColorSupport } from './colors.js'
|
|
30
|
+
import { formatElapsed, maskedCommand, paint, type ColorSupport } from '../util/index.js'
|
|
32
31
|
import { neverStarted } from './summary.js'
|
|
33
32
|
import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
|
|
34
33
|
import { fileMemo, linkPaths } from './path-links.js'
|
|
@@ -219,10 +219,49 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
219
219
|
return this.forecasts(new Set(taskIds))
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Whether the last forecast read found some task with no timing on this
|
|
224
|
+
* branch (on any branch when the provider has none): what a
|
|
225
|
+
* default-branch import is for. Learned by the read itself, so asking
|
|
226
|
+
* costs nothing (a query per task was 70 ms at 8,002 tasks).
|
|
227
|
+
*/
|
|
228
|
+
lackedBranchHistory = false
|
|
229
|
+
|
|
230
|
+
/** Whether timings of default-branch commit `sha` were imported. */
|
|
231
|
+
hasImported(sha: string): boolean {
|
|
232
|
+
return (
|
|
233
|
+
this.db
|
|
234
|
+
.query(`SELECT 1 FROM task_durations WHERE source = 'remote' AND sha = ? LIMIT 1`)
|
|
235
|
+
.get(sha) !== null
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Keep default-branch commit `sha`'s timings (task id → ms) as forecast
|
|
241
|
+
* rows: one per task, so a repeat import, by this worktree or another
|
|
242
|
+
* at once, adds nothing.
|
|
243
|
+
*/
|
|
244
|
+
importTimings(sha: string, timings: ReadonlyMap<string, number>): void {
|
|
245
|
+
const insert = this.db.query(
|
|
246
|
+
`INSERT OR IGNORE INTO task_durations(project, task, source, sha, duration_ms, recorded_at)
|
|
247
|
+
VALUES (?, ?, 'remote', ?, ?, ?)`,
|
|
248
|
+
)
|
|
249
|
+
const now = Date.now()
|
|
250
|
+
this.db
|
|
251
|
+
.transaction(() => {
|
|
252
|
+
for (const [id, ms] of timings) {
|
|
253
|
+
const [project, task] = splitTaskId(id)
|
|
254
|
+
insert.run(project, task, sha, Math.round(ms), now)
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
.immediate()
|
|
258
|
+
}
|
|
259
|
+
|
|
222
260
|
/**
|
|
223
261
|
* Each task's duration forecast (owner, 2026-10-14): the median of its
|
|
224
|
-
* last `FORECAST_RUNS` timings on this branch, else
|
|
225
|
-
*
|
|
262
|
+
* last `FORECAST_RUNS` timings on this branch, else the default branch's
|
|
263
|
+
* newest imported timing (`importTimings`), else the median on any
|
|
264
|
+
* branch, else none. The timings are the shared store's `task_durations`, so every
|
|
226
265
|
* worktree of the repo reads them and a new one forecasts on its first
|
|
227
266
|
* run. A point read per task on `(project, task, source, id)`: exact
|
|
228
267
|
* however old the rows, where the runs window holds only the newest
|
|
@@ -230,9 +269,9 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
230
269
|
*/
|
|
231
270
|
private forecasts(wanted: ReadonlySet<string>): Map<string, number> {
|
|
232
271
|
const out = new Map<string, number>()
|
|
272
|
+
this.lackedBranchHistory = false
|
|
233
273
|
if (wanted.size === 0) return out
|
|
234
|
-
// Tiers, first answer wins.
|
|
235
|
-
// (#3563) slot in between the two.
|
|
274
|
+
// Tiers, first answer wins.
|
|
236
275
|
type Tier = (project: string, task: string) => Array<{ ms: number }>
|
|
237
276
|
const tiers: Tier[] = []
|
|
238
277
|
const newest = (where: string) =>
|
|
@@ -245,18 +284,35 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
245
284
|
const q = newest('source = ?')
|
|
246
285
|
tiers.push((p, t) => q.all(p, t, source) as Array<{ ms: number }>)
|
|
247
286
|
}
|
|
287
|
+
// One probe on the partial index first: a store with no import costs
|
|
288
|
+
// no query per task.
|
|
289
|
+
if (
|
|
290
|
+
this.db.query(`SELECT 1 FROM task_durations WHERE source = 'remote' LIMIT 1`).get() !== null
|
|
291
|
+
) {
|
|
292
|
+
const remote = this.db.query(
|
|
293
|
+
`SELECT duration_ms AS ms FROM task_durations WHERE project = ? AND task = ? AND source = 'remote'
|
|
294
|
+
ORDER BY id DESC LIMIT 1`,
|
|
295
|
+
)
|
|
296
|
+
tiers.push((p, t) => remote.all(p, t) as Array<{ ms: number }>)
|
|
297
|
+
}
|
|
248
298
|
const any = newest(`source LIKE 'branch:%'`)
|
|
249
299
|
tiers.push((p, t) => any.all(p, t) as Array<{ ms: number }>)
|
|
300
|
+
const own = this.branch !== undefined ? tiers[0] : tiers.at(-1)
|
|
301
|
+
let lacked = false
|
|
250
302
|
for (const id of wanted) {
|
|
251
303
|
const [project, task] = splitTaskId(id)
|
|
304
|
+
let answered: Tier | undefined
|
|
252
305
|
for (const tier of tiers) {
|
|
253
306
|
const rows = tier(project, task)
|
|
254
307
|
if (rows.length === 0) continue
|
|
255
308
|
const durations = rows.map((r) => r.ms).sort((x, y) => x - y)
|
|
256
309
|
out.set(id, pickPercentile(durations, 0.5))
|
|
310
|
+
answered = tier
|
|
257
311
|
break
|
|
258
312
|
}
|
|
313
|
+
if (answered !== own) lacked = true
|
|
259
314
|
}
|
|
315
|
+
this.lackedBranchHistory = lacked
|
|
260
316
|
return out
|
|
261
317
|
}
|
|
262
318
|
|
|
@@ -37,7 +37,6 @@ export { forwardedSignal } from './signals.js'
|
|
|
37
37
|
// `vx cache clean` waits for a run on the workspace before it evicts.
|
|
38
38
|
export { acquireRunLock } from './run-lock.js'
|
|
39
39
|
export { defaultLogger, resolveOutputView } from './logger.js'
|
|
40
|
-
export { detectColors, paint, type ColorSupport } from './colors.js'
|
|
41
40
|
export type { Logger, OutputView } from './logger.js'
|
|
42
41
|
export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
|
|
43
42
|
export {
|
|
@@ -95,6 +94,8 @@ export {
|
|
|
95
94
|
type PluginHookHandlers,
|
|
96
95
|
type PluginHookName,
|
|
97
96
|
type PluginSetupContext,
|
|
97
|
+
type RunReport,
|
|
98
|
+
type RunReportTask,
|
|
98
99
|
type FingerprintChange,
|
|
99
100
|
type FingerprintClaim,
|
|
100
101
|
type FingerprintContext,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
2
2
|
import { settingEnv } from '../settings.js'
|
|
3
|
-
import { detectColors, type ColorSupport } from './colors.js'
|
|
4
3
|
import {
|
|
5
4
|
formatFrameClose,
|
|
6
5
|
formatFrameOpen,
|
|
@@ -21,7 +20,7 @@ import {
|
|
|
21
20
|
type StatusStream,
|
|
22
21
|
type WorkerSlot,
|
|
23
22
|
} from './status-line.js'
|
|
24
|
-
import { formatSummarySection, neverStarted, type RunContext } from './summary.js'
|
|
23
|
+
import { formatSummarySection, neverStarted, ranUnsaved, type RunContext } from './summary.js'
|
|
25
24
|
import { isGroupTask } from '../graph/index.js'
|
|
26
25
|
import {
|
|
27
26
|
appendTail,
|
|
@@ -30,6 +29,8 @@ import {
|
|
|
30
29
|
resetTail,
|
|
31
30
|
tailText,
|
|
32
31
|
type Tail,
|
|
32
|
+
detectColors,
|
|
33
|
+
type ColorSupport,
|
|
33
34
|
} from '../util/index.js'
|
|
34
35
|
import { isCacheHit } from './telemetry.js'
|
|
35
36
|
import { failedLabel, outcomeWord, ranNoCache } from './events.js'
|
|
@@ -329,6 +330,8 @@ export function defaultLogger(
|
|
|
329
330
|
let upToDate = 0
|
|
330
331
|
let restoredLocal = 0
|
|
331
332
|
let restoredRemote = 0
|
|
333
|
+
let saved = 0
|
|
334
|
+
let notSaved = 0
|
|
332
335
|
let skippedCount = 0
|
|
333
336
|
let savedMs = 0
|
|
334
337
|
// Cache-miss duration spread, accumulated incrementally (the same
|
|
@@ -445,6 +448,8 @@ export function defaultLogger(
|
|
|
445
448
|
upToDate,
|
|
446
449
|
restoredLocal,
|
|
447
450
|
restoredRemote,
|
|
451
|
+
saved,
|
|
452
|
+
notSaved,
|
|
448
453
|
miss: succeeded + failed - noCache,
|
|
449
454
|
noCache,
|
|
450
455
|
savedMs,
|
|
@@ -836,6 +841,8 @@ export function defaultLogger(
|
|
|
836
841
|
case 'success':
|
|
837
842
|
succeeded++
|
|
838
843
|
if (ranNoCache(outcome)) noCache++
|
|
844
|
+
if (outcome.saved === true) saved++
|
|
845
|
+
else if (ranUnsaved(outcome)) notSaved++
|
|
839
846
|
break
|
|
840
847
|
case 'cache-hit':
|
|
841
848
|
if (outcome.restored === false) upToDate++
|
|
@@ -21,6 +21,10 @@ import { clampInt, MASKED, secretNamed } from '../util/index.js'
|
|
|
21
21
|
import type { TaskInputComponent } from './task-hash.js'
|
|
22
22
|
import { isPassStatus } from './telemetry.js'
|
|
23
23
|
|
|
24
|
+
// A recorded `cache_policy` is the `--cache-policy` spec (`local:rw,remote:r`).
|
|
25
|
+
const readsCache = (spec: string): boolean => /(?:^|,)(?:local|remote):w?r/.test(spec)
|
|
26
|
+
const writesCache = (spec: string): boolean => /(?:^|,)(?:local|remote):r?w/.test(spec)
|
|
27
|
+
|
|
24
28
|
// ---------------------------------------------------------------------------
|
|
25
29
|
// Run listing + detail
|
|
26
30
|
// ---------------------------------------------------------------------------
|
|
@@ -412,7 +416,7 @@ function unchangedKeyNote(
|
|
|
412
416
|
p: string
|
|
413
417
|
} | null
|
|
414
418
|
)?.p
|
|
415
|
-
if (policy !== undefined && !policy
|
|
419
|
+
if (policy !== undefined && !readsCache(policy)) {
|
|
416
420
|
return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache-policy without read)'
|
|
417
421
|
}
|
|
418
422
|
// `get` answers null, not undefined, for no row.
|
|
@@ -438,7 +442,7 @@ function unchangedKeyNote(
|
|
|
438
442
|
const row = db
|
|
439
443
|
.query('SELECT cache_policy AS p, failed_count AS f FROM invocations WHERE run_id = ?')
|
|
440
444
|
.get(id) as { p: string; f: number } | null
|
|
441
|
-
const writes = row
|
|
445
|
+
const writes = row !== null && writesCache(row.p)
|
|
442
446
|
return writes ? (row?.f ?? 0) : 0
|
|
443
447
|
}
|
|
444
448
|
const before = failedWithWrites(prev.runId)
|
|
@@ -472,7 +476,7 @@ function unchangedKeyNote(
|
|
|
472
476
|
p: string
|
|
473
477
|
} | null
|
|
474
478
|
)?.p
|
|
475
|
-
if (prevPolicy !== undefined && !prevPolicy
|
|
479
|
+
if (prevPolicy !== undefined && !writesCache(prevPolicy)) {
|
|
476
480
|
return 'cache key unchanged — the previous run on this key did not write the cache (--no-cache, or a --cache-policy without write), so there was nothing to hit'
|
|
477
481
|
}
|
|
478
482
|
return 'cache key unchanged — the previous run on this key executed but no entry for it is in the cache (its save failed, or it was pruned since), so there was nothing to hit'
|