@vzn/vx 0.0.652 → 0.0.653
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +1 -0
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +25 -3
- package/src/cache/archive.ts +196 -31
- package/src/cache/cache.ts +863 -225
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +22 -5
- package/src/cache/index.ts +10 -1
- package/src/cache/inputs.ts +573 -152
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +23 -10
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +50 -10
- package/src/cache/schema.ts +42 -9
- package/src/cli/cache.ts +147 -34
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +25 -2
- package/src/cli/index.ts +38 -7
- package/src/cli/info.ts +4 -3
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +184 -41
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +63 -18
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +72 -8
- package/src/cli/watch-set.ts +6 -4
- package/src/cli/watch.ts +179 -25
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +9 -0
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +76 -15
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-runtime.ts +134 -37
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +173 -38
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +327 -141
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +15 -5
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +189 -69
- package/src/index.ts +2 -1
- package/src/orchestrator/admission.ts +13 -9
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +41 -12
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/excluded-keys.ts +14 -2
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +564 -123
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +136 -87
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +25 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +33 -53
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +31 -17
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +81 -15
- package/src/orchestrator/plan.ts +37 -15
- package/src/orchestrator/plugin-host.ts +232 -108
- package/src/orchestrator/plugin.ts +53 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +43 -14
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +12 -10
- package/src/orchestrator/run-report.ts +2 -1
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +443 -107
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +286 -59
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +20 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +17 -9
- package/src/orchestrator/task-artifacts.ts +15 -2
- package/src/orchestrator/task-hash.ts +187 -30
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +80 -12
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +20 -3
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +165 -157
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +107 -30
- package/src/report/plain-output.ts +13 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +57 -14
- package/src/settings.ts +42 -6
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +17 -3
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +50 -6
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +16 -0
- package/src/util/size.ts +12 -2
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +126 -25
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +10 -1
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +46 -25
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
|
@@ -15,10 +15,17 @@ import type { GitFilesCache } from '../cache/index.js'
|
|
|
15
15
|
import type { TaskNode } from '../graph/index.js'
|
|
16
16
|
import { xxh3hex } from '../util/index.js'
|
|
17
17
|
import type { StableKey } from './stable-keys.js'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
hashTaskConfig,
|
|
20
|
+
inputStrategiesOf,
|
|
21
|
+
isKeyless,
|
|
22
|
+
taskFilesToken,
|
|
23
|
+
workspaceCleanOf,
|
|
24
|
+
type HashCache,
|
|
25
|
+
} from './task-hash.js'
|
|
19
26
|
|
|
20
27
|
/** Moves every remembered run: bump it when what a token stands for changes. */
|
|
21
|
-
const RUN_MEMO_VERSION = 'run-memo-
|
|
28
|
+
const RUN_MEMO_VERSION = 'run-memo-v2'
|
|
22
29
|
|
|
23
30
|
export interface RunKeysArgs {
|
|
24
31
|
nodes: Map<string, TaskNode>
|
|
@@ -64,6 +71,11 @@ export function runKeysToken(args: RunKeysArgs): RunKeysAt | undefined {
|
|
|
64
71
|
JSON.stringify([...args.nestedDirsByProject]),
|
|
65
72
|
// Which outputs git ignores decides which a reader may see (`readableOutputs`).
|
|
66
73
|
[...args.gitFilesCache.ignoredOutputs].sort().join('\0'),
|
|
74
|
+
// Folded into a config's key; a remembered graph's token names the
|
|
75
|
+
// lock, not `.vx/workspace` or the variables.
|
|
76
|
+
String(workspaceCleanOf(args.hashCache)),
|
|
77
|
+
String(inputStrategiesOf(args.hashCache)?.run),
|
|
78
|
+
String(inputStrategiesOf(args.hashCache)?.workspace),
|
|
67
79
|
]
|
|
68
80
|
// A remembered graph's token names every node's edges and config, so
|
|
69
81
|
// each node adds only what may differ under it (4 of 8 ms at 1,206
|
|
@@ -75,7 +87,20 @@ export function runKeysToken(args: RunKeysArgs): RunKeysAt | undefined {
|
|
|
75
87
|
// Tasks of one project mostly declare the same files and outputs, and the
|
|
76
88
|
// token is a function of that declaration (`filesToken`): one walk each,
|
|
77
89
|
// not one per task (5 of 12 ms at 1,206 tasks, 2026-10-23).
|
|
78
|
-
|
|
90
|
+
// Keyed by the project, then by the declaration's text, and that text is
|
|
91
|
+
// taken once per config object: the lock shares a config among the tasks
|
|
92
|
+
// that declare it alike, and a stringify and a joined key per node were
|
|
93
|
+
// ~6 ms of 9,603 (2026-11-07).
|
|
94
|
+
const tokens = new Map<string, Map<string, string>>()
|
|
95
|
+
const declarations = new Map<TaskNode['config'], string>()
|
|
96
|
+
const declarationOf = (cfg: TaskNode['config']): string => {
|
|
97
|
+
let text = declarations.get(cfg)
|
|
98
|
+
if (text === undefined) {
|
|
99
|
+
text = JSON.stringify([cfg.inputs?.files, cfg.outputs?.files, cfg.outputs?.workspaceFiles])
|
|
100
|
+
declarations.set(cfg, text)
|
|
101
|
+
}
|
|
102
|
+
return text
|
|
103
|
+
}
|
|
79
104
|
const filesAt: number[] = []
|
|
80
105
|
for (const node of args.nodes.values()) {
|
|
81
106
|
const cfg = node.config
|
|
@@ -84,24 +109,29 @@ export function runKeysToken(args: RunKeysArgs): RunKeysAt | undefined {
|
|
|
84
109
|
if ((inputs?.runtime?.length ?? 0) > 0 || (inputs?.workspaceRuntime?.length ?? 0) > 0) {
|
|
85
110
|
return undefined
|
|
86
111
|
}
|
|
112
|
+
// A files token in full is a root, a declaration and a digest, ~200
|
|
113
|
+
// characters per node, joined and hashed again: 2 MB of token at 8,002
|
|
114
|
+
// tasks (2026-11-07). Each distinct one is hashed once instead, and the
|
|
115
|
+
// node names that hash, which is also what the delta compares.
|
|
87
116
|
let files = ''
|
|
88
117
|
if (inputs !== undefined) {
|
|
89
118
|
if ((inputs.workspaceFiles?.length ?? 0) > 0) return undefined
|
|
90
119
|
// No positive entry resolves no files (`filesPlan`).
|
|
91
120
|
if (inputs.files === undefined || inputs.files.some((e) => !e.startsWith('!'))) {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
121
|
+
const declared = declarationOf(cfg)
|
|
122
|
+
let byDeclared = tokens.get(node.projectName)
|
|
123
|
+
if (byDeclared === undefined) tokens.set(node.projectName, (byDeclared = new Map()))
|
|
124
|
+
let token = byDeclared.get(declared)
|
|
95
125
|
if (token === undefined) {
|
|
96
|
-
|
|
126
|
+
const full = taskFilesToken({
|
|
97
127
|
node,
|
|
98
128
|
workspaceRoot: args.workspaceRoot,
|
|
99
129
|
nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
|
|
100
130
|
gitFilesCache: args.gitFilesCache,
|
|
101
|
-
hashCache: args.hashCache,
|
|
102
131
|
})
|
|
103
|
-
if (
|
|
104
|
-
|
|
132
|
+
if (full === undefined) return undefined
|
|
133
|
+
token = xxh3hex(full)
|
|
134
|
+
byDeclared.set(declared, token)
|
|
105
135
|
}
|
|
106
136
|
files = token
|
|
107
137
|
}
|
|
@@ -139,13 +169,9 @@ export function runKeysToken(args: RunKeysArgs): RunKeysAt | undefined {
|
|
|
139
169
|
at.delta = () => {
|
|
140
170
|
if (delta !== undefined) return delta
|
|
141
171
|
const files = new Map<string, string>()
|
|
142
|
-
const hashed = new Map<string, string>()
|
|
143
172
|
const rest = [...parts]
|
|
144
173
|
filesAt.forEach((i, n) => {
|
|
145
|
-
|
|
146
|
-
let h = hashed.get(token)
|
|
147
|
-
if (h === undefined) hashed.set(token, (h = xxh3hex(token)))
|
|
148
|
-
files.set(ids[n]!, h)
|
|
174
|
+
files.set(ids[n]!, parts[i]!)
|
|
149
175
|
rest[i] = ''
|
|
150
176
|
})
|
|
151
177
|
return (delta = { global: `${selection}\0${xxh3hex(rest.join('\0'))}`, files })
|
|
@@ -154,6 +180,21 @@ export function runKeysToken(args: RunKeysArgs): RunKeysAt | undefined {
|
|
|
154
180
|
return at
|
|
155
181
|
}
|
|
156
182
|
|
|
183
|
+
/**
|
|
184
|
+
* A memo row: each stable and uncached key with its node's place in the
|
|
185
|
+
* run's node order. The token names every node id in that order, so a row
|
|
186
|
+
* read under it means the same nodes; by id, the row's JSON and a lookup
|
|
187
|
+
* per node were ~5 ms of 8,002 (2026-11-07).
|
|
188
|
+
*/
|
|
189
|
+
interface KeysRow {
|
|
190
|
+
/** Stable keys: node places, and their keys alike. */
|
|
191
|
+
s: number[]
|
|
192
|
+
h: string[]
|
|
193
|
+
/** Uncached keys: node places, and their keys alike. */
|
|
194
|
+
u: number[]
|
|
195
|
+
k: string[]
|
|
196
|
+
}
|
|
197
|
+
|
|
157
198
|
/** The keys remembered under `token`, mapped back onto this run's nodes. */
|
|
158
199
|
export function readRunKeys(
|
|
159
200
|
args: RunKeysArgs,
|
|
@@ -161,19 +202,28 @@ export function readRunKeys(
|
|
|
161
202
|
): RunKeys | undefined {
|
|
162
203
|
const row = args.hashCache.folds?.get(at.id, at.token)
|
|
163
204
|
if (row === undefined) return undefined
|
|
164
|
-
let parsed:
|
|
205
|
+
let parsed: KeysRow
|
|
165
206
|
try {
|
|
166
207
|
parsed = JSON.parse(row)
|
|
167
208
|
} catch {
|
|
168
209
|
return undefined
|
|
169
210
|
}
|
|
211
|
+
const { s, h, u, k } = parsed
|
|
212
|
+
if (s?.length !== h?.length || u?.length !== k?.length) return undefined
|
|
213
|
+
const nodes = [...args.nodes.values()]
|
|
170
214
|
const stableKeys: StableKey[] = []
|
|
171
|
-
for (
|
|
172
|
-
const node =
|
|
215
|
+
for (let i = 0; i < s.length; i++) {
|
|
216
|
+
const node = nodes[s[i]!]
|
|
217
|
+
if (node === undefined) return undefined
|
|
218
|
+
stableKeys.push({ hash: h[i]!, node })
|
|
219
|
+
}
|
|
220
|
+
const uncachedKeys = new Map<string, string>()
|
|
221
|
+
for (let i = 0; i < u.length; i++) {
|
|
222
|
+
const node = nodes[u[i]!]
|
|
173
223
|
if (node === undefined) return undefined
|
|
174
|
-
|
|
224
|
+
uncachedKeys.set(node.id, k[i]!)
|
|
175
225
|
}
|
|
176
|
-
return { stableKeys, uncachedKeys
|
|
226
|
+
return { stableKeys, uncachedKeys }
|
|
177
227
|
}
|
|
178
228
|
|
|
179
229
|
/**
|
|
@@ -259,14 +309,23 @@ export function writeRunKeys(
|
|
|
259
309
|
at: { id: string; token: string },
|
|
260
310
|
keys: RunKeys,
|
|
261
311
|
): void {
|
|
262
|
-
args.hashCache.folds
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
312
|
+
if (args.hashCache.folds === undefined) return
|
|
313
|
+
const place = new Map<string, number>()
|
|
314
|
+
for (const id of args.nodes.keys()) place.set(id, place.size)
|
|
315
|
+
const row: KeysRow = { s: [], h: [], u: [], k: [] }
|
|
316
|
+
for (const key of keys.stableKeys) {
|
|
317
|
+
const n = place.get(key.node.id)
|
|
318
|
+
if (n === undefined) return
|
|
319
|
+
row.s.push(n)
|
|
320
|
+
row.h.push(key.hash)
|
|
321
|
+
}
|
|
322
|
+
for (const [id, key] of keys.uncachedKeys) {
|
|
323
|
+
const n = place.get(id)
|
|
324
|
+
if (n === undefined) return
|
|
325
|
+
row.u.push(n)
|
|
326
|
+
row.k.push(key)
|
|
327
|
+
}
|
|
328
|
+
args.hashCache.folds.set(at.id, at.token, JSON.stringify(row))
|
|
270
329
|
}
|
|
271
330
|
|
|
272
331
|
let identity: string | undefined
|
|
@@ -43,6 +43,8 @@ export interface LiveTask {
|
|
|
43
43
|
durationMs: number | null
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
type Ended = { status: OutcomeView['status']; startedAt: number | null; durationMs: number }
|
|
47
|
+
|
|
46
48
|
/** What `vx last --live --format json` prints, the snapshot line. */
|
|
47
49
|
export interface LiveSnapshot {
|
|
48
50
|
live: { runId: string; command: string; pid: number; startedAt: number; elapsedMs: number }
|
|
@@ -113,11 +115,8 @@ export function serveLive(opts: {
|
|
|
113
115
|
nodes: () => Iterable<TaskNode>
|
|
114
116
|
warnings: () => readonly string[]
|
|
115
117
|
}): { unlisten(): void; dispose(): void } {
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
string,
|
|
119
|
-
OutcomeView['status'] | { status: OutcomeView['status']; durationMs: number }
|
|
120
|
-
>()
|
|
118
|
+
// Each task's start, then its end: one map, not one for each.
|
|
119
|
+
const seen = new Map<string, number | Ended>()
|
|
121
120
|
const peers = new Map<Socket<Peer>, Peer>()
|
|
122
121
|
let followers = 0
|
|
123
122
|
|
|
@@ -155,12 +154,11 @@ export function serveLive(opts: {
|
|
|
155
154
|
const tasks: LiveTask[] = []
|
|
156
155
|
for (const node of opts.nodes()) {
|
|
157
156
|
if (isGroupTask(node)) continue
|
|
158
|
-
const at =
|
|
159
|
-
const end = ended.get(node.id)
|
|
157
|
+
const at = seen.get(node.id)
|
|
160
158
|
tasks.push(
|
|
161
|
-
|
|
162
|
-
? { taskId: node.id,
|
|
163
|
-
: at !==
|
|
159
|
+
typeof at === 'object'
|
|
160
|
+
? { taskId: node.id, ...at }
|
|
161
|
+
: at !== undefined
|
|
164
162
|
? { taskId: node.id, status: 'running', startedAt: at, durationMs: null }
|
|
165
163
|
: { taskId: node.id, status: 'waiting', startedAt: null, durationMs: null },
|
|
166
164
|
)
|
|
@@ -184,10 +182,15 @@ export function serveLive(opts: {
|
|
|
184
182
|
}
|
|
185
183
|
|
|
186
184
|
const unsubscribe = opts.bus.subscribe((event) => {
|
|
187
|
-
if (event.kind === 'task:start')
|
|
188
|
-
|
|
189
|
-
|
|
185
|
+
if (event.kind === 'task:start') {
|
|
186
|
+
const at = seen.get(event.node.id)
|
|
187
|
+
if (typeof at === 'object') at.startedAt = Date.now()
|
|
188
|
+
else seen.set(event.node.id, Date.now())
|
|
189
|
+
} else if (event.kind === 'task:complete') {
|
|
190
|
+
const at = seen.get(event.node.id)
|
|
191
|
+
seen.set(event.node.id, {
|
|
190
192
|
status: event.outcome.status,
|
|
193
|
+
startedAt: typeof at === 'object' ? at.startedAt : (at ?? null),
|
|
191
194
|
durationMs: event.outcome.durationMs,
|
|
192
195
|
})
|
|
193
196
|
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// run's restore is staging takes its files out from under it (item 215).
|
|
6
6
|
// The cache itself is safe (SQLite waits, and an artifact lands by rename
|
|
7
7
|
// inside its rows' transaction, A-3), across workspaces sharing one
|
|
8
|
-
// `--cache-dir` too
|
|
8
|
+
// `--cache-dir` too (a clean of it waits for their runs, `holdCacheDir`);
|
|
9
|
+
// the tree is not, and a run that finished "green" can
|
|
9
10
|
// have had its restored outputs deleted by the other run's clean a moment
|
|
10
11
|
// later. So a run takes this lock before it schedules and releases it with
|
|
11
12
|
// its cache handle — before a persistent task's wait, so a dev server never
|
|
@@ -62,7 +63,7 @@
|
|
|
62
63
|
// them and left by the last to release.
|
|
63
64
|
|
|
64
65
|
import { chmodSync, lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
65
|
-
import { rmdirSync, unlinkSync } from 'node:fs'
|
|
66
|
+
import { rmdirSync, rmSync, unlinkSync } from 'node:fs'
|
|
66
67
|
import { mkdir, readdir, readFile, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises'
|
|
67
68
|
import os from 'node:os'
|
|
68
69
|
import path from 'node:path'
|
|
@@ -79,6 +80,12 @@ const takers = new Map<string, string>()
|
|
|
79
80
|
* must wait for it, not reclaim it as a dead run of this pid.
|
|
80
81
|
*/
|
|
81
82
|
const placed = new Set<string>()
|
|
83
|
+
/**
|
|
84
|
+
* Staging directories made and not yet renamed or removed: an exit between
|
|
85
|
+
* the mkdir and the rename left one, randomly named, in the lock root for
|
|
86
|
+
* good, no later taking knowing its name.
|
|
87
|
+
*/
|
|
88
|
+
const staged = new Set<string>()
|
|
82
89
|
|
|
83
90
|
/**
|
|
84
91
|
* A signal exit is `process.exit` (signals.ts): no finally runs and nothing
|
|
@@ -92,6 +99,13 @@ function releaseOnExit(): void {
|
|
|
92
99
|
if (exitHooked) return
|
|
93
100
|
exitHooked = true
|
|
94
101
|
process.on('exit', () => {
|
|
102
|
+
for (const dir of staged) {
|
|
103
|
+
try {
|
|
104
|
+
rmSync(dir, { recursive: true, force: true })
|
|
105
|
+
} catch {
|
|
106
|
+
// best effort on the way out
|
|
107
|
+
}
|
|
108
|
+
}
|
|
95
109
|
for (const [lockDir, entry] of takers) {
|
|
96
110
|
// The entry may be gone already: a release under way unlinked it and
|
|
97
111
|
// was still to remove the directory (item 867).
|
|
@@ -292,7 +306,15 @@ async function place(lockDir: string): Promise<string | null> {
|
|
|
292
306
|
// start time, and a restarted container's pid 1 would meet the staging
|
|
293
307
|
// directory a killed pid 1 left, and refuse every run after it.
|
|
294
308
|
const staging = `${lockDir}.${entry}.${Math.random().toString(36).slice(2)}`
|
|
295
|
-
|
|
309
|
+
// Listed before the mkdir: the exit hook removes what it lists.
|
|
310
|
+
staged.add(staging)
|
|
311
|
+
releaseOnExit()
|
|
312
|
+
try {
|
|
313
|
+
await mkdir(staging)
|
|
314
|
+
} catch (err) {
|
|
315
|
+
staged.delete(staging)
|
|
316
|
+
throw err
|
|
317
|
+
}
|
|
296
318
|
placed.add(entry)
|
|
297
319
|
try {
|
|
298
320
|
await writeFile(path.join(staging, entry), '')
|
|
@@ -304,6 +326,8 @@ async function place(lockDir: string): Promise<string | null> {
|
|
|
304
326
|
const code = (err as NodeJS.ErrnoException).code
|
|
305
327
|
if (code === 'ENOTEMPTY' || code === 'EEXIST') return null
|
|
306
328
|
throw err
|
|
329
|
+
} finally {
|
|
330
|
+
staged.delete(staging)
|
|
307
331
|
}
|
|
308
332
|
}
|
|
309
333
|
|
|
@@ -384,7 +408,6 @@ export async function acquireRunLock(
|
|
|
384
408
|
if (entry !== null) {
|
|
385
409
|
heldHere.set(lockDir, 1)
|
|
386
410
|
takers.set(lockDir, entry)
|
|
387
|
-
releaseOnExit()
|
|
388
411
|
return held
|
|
389
412
|
}
|
|
390
413
|
h = await holder(lockDir)
|
|
@@ -12,14 +12,10 @@ import { VERSION } from '../version.js'
|
|
|
12
12
|
import type { CiContext, GitContext, HostContext } from './run-context.js'
|
|
13
13
|
import { taskTelemetryOf, type TaskTelemetry } from './telemetry.js'
|
|
14
14
|
import { isCacheHit } from '../graph/index.js'
|
|
15
|
+
import { neverStarted } from '../report/index.js'
|
|
15
16
|
|
|
16
17
|
export interface RunRecordsInput {
|
|
17
18
|
outcomes: readonly TaskOutcome[]
|
|
18
|
-
/**
|
|
19
|
-
* Tasks that ran behind a failed dependency (`--continue=always`) or one
|
|
20
|
-
* `--exclude-dependencies` dropped, each with the task at the root of it.
|
|
21
|
-
*/
|
|
22
|
-
tainted?: ReadonlyMap<string, string>
|
|
23
19
|
runId: string
|
|
24
20
|
/** Wall-clock ms at run start; every per-task ns offset is anchored to it. */
|
|
25
21
|
startedAtMs: number
|
|
@@ -49,7 +45,7 @@ export interface RunRecordsInput {
|
|
|
49
45
|
/** What a task's `runs` row reads besides its outcome. */
|
|
50
46
|
export type RunRecordContext = Pick<
|
|
51
47
|
RunRecordsInput,
|
|
52
|
-
'
|
|
48
|
+
'runId' | 'startedAtMs' | 'endedAtMs' | 'forwardArgs' | 'blocks' | 'remoteTraffic'
|
|
53
49
|
>
|
|
54
50
|
|
|
55
51
|
export interface RunRecords {
|
|
@@ -89,8 +85,13 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
89
85
|
let restoredRemoteCount = 0
|
|
90
86
|
for (const o of outcomes) {
|
|
91
87
|
if (isGroupTask(o.node)) continue
|
|
92
|
-
// aborted (killed by a shutdown signal) isn't a real run.
|
|
93
|
-
|
|
88
|
+
// aborted (killed by a shutdown signal) isn't a real run. One that
|
|
89
|
+
// started is still in the summary's task list, as the terminal lists it;
|
|
90
|
+
// one the stop reached first is only `notRunCount`.
|
|
91
|
+
if (o.status === 'aborted') {
|
|
92
|
+
if (input.withTelemetry && !neverStarted(o)) telemetryTasks.push(taskTelemetryOf(o))
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
94
95
|
if (input.withTelemetry) telemetryTasks.push(taskTelemetryOf(o))
|
|
95
96
|
taskCount++
|
|
96
97
|
if (input.landed?.has(o) !== true) runs.push(runRecordOf(o as RecordedOutcome, input))
|
|
@@ -141,7 +142,7 @@ export type RecordedOutcome = TaskOutcome & { status: RunRecord['status'] }
|
|
|
141
142
|
/** One outcome's `runs` row. */
|
|
142
143
|
export function runRecordOf(o: RecordedOutcome, input: RunRecordContext): RunRecord {
|
|
143
144
|
const { runId, startedAtMs, endedAtMs } = input
|
|
144
|
-
const blockedBy = o.blockedBy ??
|
|
145
|
+
const blockedBy = o.blockedBy ?? o.taintedBy
|
|
145
146
|
const remoteError = remoteErrorOf(input.remoteTraffic, o.hash)
|
|
146
147
|
// Built field by field: a spread per optional field was ~2 µs of each
|
|
147
148
|
// of a 1,200-hit warm run's rows.
|
|
@@ -168,9 +169,10 @@ export function runRecordOf(o: RecordedOutcome, input: RunRecordContext): RunRec
|
|
|
168
169
|
? startedAtMs + Math.round(Number(o.wallclockEndNs) / 1e6)
|
|
169
170
|
: endedAtMs,
|
|
170
171
|
runId,
|
|
171
|
-
cacheHit: hit,
|
|
172
172
|
cached: o.node.config.cache === true,
|
|
173
173
|
}
|
|
174
|
+
// A hit whose restore failed is neither: its `cache_hit` stays NULL.
|
|
175
|
+
if (o.restoreFailed !== true) r.cacheHit = hit
|
|
174
176
|
if (o.hash !== undefined) r.hash = o.hash
|
|
175
177
|
// A run forwards its args to the tasks it was asked for, never to a
|
|
176
178
|
// dependency: the row says which ran with them.
|
|
@@ -26,6 +26,7 @@ export type KeepRunEntry = (name: string, bytes: Uint8Array) => Promise<boolean>
|
|
|
26
26
|
export interface RunKeeperArgs {
|
|
27
27
|
readonly runId: string
|
|
28
28
|
readonly workspaceRoot: string
|
|
29
|
+
/** Where each project's outputs stop (prepare.ts `outputWallsOf`). */
|
|
29
30
|
readonly nestedDirsByProject: ReadonlyMap<string, string[]>
|
|
30
31
|
readonly keep: KeepRunEntry
|
|
31
32
|
/** Whether a failed task's log and outputs are kept (`cache.upload.failures`). */
|
|
@@ -44,7 +45,7 @@ export function keepsFailures(where: unknown, ci: boolean): boolean {
|
|
|
44
45
|
const KEEP_CONCURRENCY = 8
|
|
45
46
|
|
|
46
47
|
/** The files `node`'s declared outputs match on disk now, workspace-relative and sorted. */
|
|
47
|
-
async function outputsLeft(
|
|
48
|
+
export async function outputsLeft(
|
|
48
49
|
node: TaskNode,
|
|
49
50
|
workspaceRoot: string,
|
|
50
51
|
nestedProjectDirs: string[],
|
|
@@ -18,6 +18,7 @@ import { hasEnded } from './persistent.js'
|
|
|
18
18
|
import {
|
|
19
19
|
forwardedSignal,
|
|
20
20
|
forwardSignals,
|
|
21
|
+
type HeldSignals,
|
|
21
22
|
SIGNAL_SHUTDOWN_GRACE_MS,
|
|
22
23
|
type StopSignal,
|
|
23
24
|
terminateChildren,
|
|
@@ -26,10 +27,11 @@ import {
|
|
|
26
27
|
type Child = ReturnType<typeof Bun.spawn>
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
|
-
* The abort reason that stops a run's own work and spares
|
|
30
|
-
*
|
|
31
|
-
* cycle waiting on a server that never
|
|
32
|
-
* stop took the unchanged servers down
|
|
30
|
+
* The abort reason that stops a run's own work and spares its ready
|
|
31
|
+
* servers, handed in (`RunOptions.keep`) or made ready by the run, which
|
|
32
|
+
* it hands back. `vx watch` stops a cycle waiting on a server that never
|
|
33
|
+
* becomes ready (WD-15); any other stop took the unchanged servers down
|
|
34
|
+
* with it, and they restarted.
|
|
33
35
|
*/
|
|
34
36
|
export const CYCLE_INTERRUPT = 'vx:cycle-interrupt'
|
|
35
37
|
|
|
@@ -77,25 +79,26 @@ export function runStop(args: {
|
|
|
77
79
|
/** `RunOptions.handleSignals`. */
|
|
78
80
|
handleSignals: boolean
|
|
79
81
|
handsTerminal: boolean
|
|
82
|
+
/** What the run heard since it went for its lock (`holdSignals`). */
|
|
83
|
+
held: HeldSignals | undefined
|
|
80
84
|
/** How many plugins have a teardown: each gets its own bound before a signal exits. */
|
|
81
85
|
teardowns: number
|
|
82
86
|
/** Called first when the run is stopped. */
|
|
83
87
|
onStop: () => void
|
|
84
|
-
/** A server the run was handed: a `CYCLE_INTERRUPT` leaves it running. */
|
|
85
|
-
handed: (child: Child) => boolean
|
|
86
88
|
}): RunStop {
|
|
87
89
|
const { log, external } = args
|
|
88
90
|
const liveChildren = new Set<Child>()
|
|
89
91
|
// A server that ends on its own while the graph still runs is said at
|
|
90
92
|
// once: its dependants were failing against it, and the end of the run
|
|
91
93
|
// was the first word of it (C-69). Not once the graph is done (the end
|
|
92
|
-
// of the run and the keep-alive wait say it) nor under a stop
|
|
94
|
+
// of the run and the keep-alive wait say it) nor under a stop, nor once
|
|
95
|
+
// the run took it out to stop it (no task left needed it).
|
|
93
96
|
let graphDone = false
|
|
94
97
|
const persistentRegistry = new (class extends Map<string, Child> {
|
|
95
98
|
override set(id: string, child: Child): this {
|
|
96
99
|
super.set(id, child)
|
|
97
100
|
void child.exited.then((code) => {
|
|
98
|
-
if (code !== 0 && !graphDone && !controller.signal.aborted)
|
|
101
|
+
if (code !== 0 && !graphDone && !controller.signal.aborted && this.get(id) === child)
|
|
99
102
|
warnOf(log)(`vx: ${id} exited with code ${code} while the run went on`)
|
|
100
103
|
})
|
|
101
104
|
return this
|
|
@@ -126,6 +129,7 @@ export function runStop(args: {
|
|
|
126
129
|
},
|
|
127
130
|
done: runLeft,
|
|
128
131
|
handsTerminal: args.handsTerminal,
|
|
132
|
+
held: args.held,
|
|
129
133
|
// The grace, then one flush and each teardown at their own bound, and
|
|
130
134
|
// slack for the summary and the cache close.
|
|
131
135
|
boundMs:
|
|
@@ -136,7 +140,11 @@ export function runStop(args: {
|
|
|
136
140
|
const onAbort = (): void => {
|
|
137
141
|
args.onStop()
|
|
138
142
|
endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
|
|
139
|
-
|
|
143
|
+
// Every ready server is in the registry, the handed ones included: the
|
|
144
|
+
// initial watch run is handed none, and its ready servers restarted.
|
|
145
|
+
const ready = new Set(persistentRegistry.values())
|
|
146
|
+
const spare = (c: Child): boolean =>
|
|
147
|
+
controller.signal.reason === CYCLE_INTERRUPT && ready.has(c)
|
|
140
148
|
aborting = terminateChildren(
|
|
141
149
|
() => [...liveChildren, ...persistentRegistry.values()].filter((c) => !spare(c)),
|
|
142
150
|
forwardedSignal(controller.signal.reason),
|