@vzn/vx 0.0.619 → 0.0.621
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch.ts +14 -7
- package/src/orchestrator/options.ts +25 -1
- package/src/orchestrator/persistent.ts +36 -0
- package/src/orchestrator/run.ts +77 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.621",
|
|
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.621",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.621",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.621",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.621"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cli/watch-judge.ts
CHANGED
|
@@ -218,7 +218,7 @@ export class ChangeJudge {
|
|
|
218
218
|
this.noticed.add(firstAbs)
|
|
219
219
|
process.stdout.write(
|
|
220
220
|
byServer
|
|
221
|
-
? `vx watch: ${first} has started 3 cycles in a row, written while a server
|
|
221
|
+
? `vx watch: ${first} has started 3 cycles in a row, written while a server was running — a persistent task rewrites it. Add it to .gitignore (a git-ignored path never starts a cycle); until then every write starts a cycle.\n`
|
|
222
222
|
: `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in cache.outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
|
|
223
223
|
)
|
|
224
224
|
}
|
package/src/cli/watch.ts
CHANGED
|
@@ -409,8 +409,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
409
409
|
let claimedRootFiles = args.claimedRootFiles
|
|
410
410
|
let configImportFiles = args.configImports
|
|
411
411
|
let wsConfigImportFiles = args.workspaceConfigImports
|
|
412
|
-
// A dev server stays up while the loop idles
|
|
413
|
-
//
|
|
412
|
+
// A dev server stays up while the loop idles and across cycles; a cycle
|
|
413
|
+
// that must restart it (its config changed, it died) stops it before its
|
|
414
|
+
// graph, so the new one never meets the old one's port.
|
|
414
415
|
let held = args.held
|
|
415
416
|
|
|
416
417
|
// Reentrancy guard — never two orchestrator runs in flight. Events that
|
|
@@ -480,20 +481,26 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
480
481
|
// after the run stays, for what the run itself changed.
|
|
481
482
|
if (reread) await rearm(false)
|
|
482
483
|
process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
|
|
483
|
-
await held?.stop()
|
|
484
|
-
held = undefined
|
|
485
|
-
// A Ctrl-C while the old server shut down ran a cycle anyway: a
|
|
486
|
-
// `not run` row and a footer printed above `stopped` (WD-22).
|
|
487
484
|
if (stop.aborted) break
|
|
488
485
|
restartTimings()
|
|
489
486
|
// On the mtime clock, as the arm is: from `Date.now()` a write the
|
|
490
487
|
// run made within a tick of it carried an earlier mtime and read as
|
|
491
488
|
// an edit (WD-20). The end needs no stamp: an mtime never leads it.
|
|
492
489
|
const start = fsClockNow(cacheDir)
|
|
493
|
-
|
|
490
|
+
// A server still up whose task is unchanged stays up: the cycle
|
|
491
|
+
// rebuilds what it depends on, and the dev tool's own reload
|
|
492
|
+
// takes it from there. The run stops the others before its graph.
|
|
493
|
+
const before = held
|
|
494
|
+
current = watchCycle(
|
|
495
|
+
{ ...opts, ...(before ? { keep: before } : {}) },
|
|
496
|
+
stop,
|
|
497
|
+
interruptIfWaiting,
|
|
498
|
+
)
|
|
494
499
|
const cycle = await runOrchestrator(current.opts)
|
|
495
500
|
current = undefined
|
|
496
501
|
held = cycle.persistent
|
|
502
|
+
// What this cycle did not take (a server whose task did not run).
|
|
503
|
+
await before?.stop()
|
|
497
504
|
if (cycle.refused !== undefined) process.stderr.write(`vx watch: ${cycle.refused}\n`)
|
|
498
505
|
changes.lastCycle = { start, end: Date.now() }
|
|
499
506
|
} catch (err) {
|
|
@@ -214,6 +214,18 @@ export interface RunOptions {
|
|
|
214
214
|
* is replaced only when the next cycle starts.
|
|
215
215
|
*/
|
|
216
216
|
holdPersistent?: boolean
|
|
217
|
+
/**
|
|
218
|
+
* The servers an earlier `holdPersistent` run handed back. Each one still
|
|
219
|
+
* up whose task this run would start again, with the same resolved
|
|
220
|
+
* config, is kept running and reported ready instead of restarted; what
|
|
221
|
+
* it writes reaches this run's renderer from then on. The rest are
|
|
222
|
+
* stopped before the graph starts, so a new one never meets the old
|
|
223
|
+
* one's port. The watch loop sets it: `vx watch dev` rebuilds what the
|
|
224
|
+
* server depends on and leaves the server up. The caller still stops
|
|
225
|
+
* the earlier hand-back afterwards; that stops only what this run did
|
|
226
|
+
* not take.
|
|
227
|
+
*/
|
|
228
|
+
keep?: HeldPersistent
|
|
217
229
|
/**
|
|
218
230
|
* Print the per-task table (`--verbosity 1`) just above the footer: the
|
|
219
231
|
* footer is the run's last word, so the caller cannot print it after.
|
|
@@ -305,6 +317,18 @@ export interface RunSummary {
|
|
|
305
317
|
export interface HeldPersistent {
|
|
306
318
|
/** The held tasks' ids, `pkg#task`. */
|
|
307
319
|
ids: readonly string[]
|
|
308
|
-
/** Send every held task `signal` (default SIGTERM), then SIGKILL after the kill grace; resolves once each is gone. */
|
|
320
|
+
/** Send every held task a later run did not take `signal` (default SIGTERM), then SIGKILL after the kill grace; resolves once each is gone. */
|
|
309
321
|
stop(signal?: 'SIGINT' | 'SIGTERM'): Promise<void>
|
|
322
|
+
/** Each held server by id, for a later run's `RunOptions.keep`. */
|
|
323
|
+
servers: ReadonlyMap<string, HeldServer>
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export interface HeldServer {
|
|
327
|
+
child: ReturnType<typeof Bun.spawn>
|
|
328
|
+
/** The task's resolved config as the run that started it saw it: a later run keeps the server only while its own reads the same. */
|
|
329
|
+
config: string
|
|
330
|
+
/** Where what the server writes goes; a run that keeps it points this at its own bus. */
|
|
331
|
+
out: { bus: EventBus }
|
|
332
|
+
/** A later run took the server (kept or stopped it): the hand-back's `stop()` and exit notice leave it alone. */
|
|
333
|
+
handOver(): void
|
|
310
334
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { isGroupTask, type TaskNode } from '../graph/index.js'
|
|
7
7
|
import { killGraceMs } from '../util/index.js'
|
|
8
8
|
import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
|
|
9
|
+
import type { HeldServer } from './options.js'
|
|
9
10
|
|
|
10
11
|
type Child = ReturnType<typeof Bun.spawn>
|
|
11
12
|
|
|
@@ -116,3 +117,38 @@ export interface CrashedPersistent {
|
|
|
116
117
|
export function hasEnded(child: Child): boolean {
|
|
117
118
|
return child.exitCode !== null || child.signalCode !== null
|
|
118
119
|
}
|
|
120
|
+
|
|
121
|
+
/** A server's identity across watch cycles: its resolved config and the args forwarded to it. */
|
|
122
|
+
export function serverConfig(node: TaskNode, forwardArgs: readonly string[] | undefined): string {
|
|
123
|
+
return JSON.stringify([node.config, forwardArgs ?? []])
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Split an earlier run's held servers into the ones this graph may keep
|
|
128
|
+
* (still up, a node of this graph, same config) and the ones it stops. A
|
|
129
|
+
* stopped one is handed over now, so the earlier run names no exit; a kept
|
|
130
|
+
* one only when its task is reached, and one never reached (a dependency
|
|
131
|
+
* failed) is the earlier run's to stop.
|
|
132
|
+
*/
|
|
133
|
+
export function takeHeldServers(
|
|
134
|
+
held: ReadonlyMap<string, HeldServer> | undefined,
|
|
135
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
136
|
+
forwardArgs: readonly string[] | undefined,
|
|
137
|
+
): { kept: Map<string, HeldServer>; stale: Child[] } {
|
|
138
|
+
const kept = new Map<string, HeldServer>()
|
|
139
|
+
const stale: Child[] = []
|
|
140
|
+
for (const [id, server] of held ?? []) {
|
|
141
|
+
if (hasEnded(server.child)) continue
|
|
142
|
+
const node = nodes.get(id)
|
|
143
|
+
if (
|
|
144
|
+
node?.config.exec?.persistent !== undefined &&
|
|
145
|
+
serverConfig(node, forwardArgs) === server.config
|
|
146
|
+
)
|
|
147
|
+
kept.set(id, server)
|
|
148
|
+
else {
|
|
149
|
+
server.handOver()
|
|
150
|
+
stale.push(server.child)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { kept, stale }
|
|
154
|
+
}
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -63,7 +63,7 @@ import { buildAdmission, executorLabel, resolveExecutors, teardownPlugins } from
|
|
|
63
63
|
import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
64
64
|
import { assembleRunSummary, isPassStatus } from './telemetry.js'
|
|
65
65
|
import type { RunContextRecord } from './telemetry.js'
|
|
66
|
-
import { defaultLogger, resolveOutputView } from './logger.js'
|
|
66
|
+
import { defaultLogger, resolveOutputView, type Logger } from './logger.js'
|
|
67
67
|
import { detectColors, type ColorSupport } from './colors.js'
|
|
68
68
|
import { plainOutput } from './plain-output.js'
|
|
69
69
|
import { formatPersistentList } from './framed-output.js'
|
|
@@ -103,13 +103,19 @@ import {
|
|
|
103
103
|
import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
|
|
104
104
|
|
|
105
105
|
import { assembleRunRecords } from './run-records.js'
|
|
106
|
-
import {
|
|
106
|
+
import {
|
|
107
|
+
hasEnded,
|
|
108
|
+
selectKeepAlive,
|
|
109
|
+
serverConfig,
|
|
110
|
+
shutdownPersistent,
|
|
111
|
+
takeHeldServers,
|
|
112
|
+
} from './persistent.js'
|
|
107
113
|
import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
|
|
108
114
|
import { createSaveLane } from './save-lane.js'
|
|
109
115
|
import { formatOutcomeTable, formatRunSummary } from './summary.js'
|
|
110
116
|
import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
|
|
111
117
|
import { createMissExplainer } from './miss-reason.js'
|
|
112
|
-
import type { RunOptions, RunSummary } from './options.js'
|
|
118
|
+
import type { HeldServer, RunOptions, RunSummary } from './options.js'
|
|
113
119
|
|
|
114
120
|
// Per run, never shared: a `vx watch` process runs many, and a shared map
|
|
115
121
|
// is one `preProbed.set` away from leaking a hit across cycles.
|
|
@@ -358,6 +364,18 @@ async function runOnBus(
|
|
|
358
364
|
colors: ColorSupport,
|
|
359
365
|
): Promise<RunSummary> {
|
|
360
366
|
const log = busLogger(bus)
|
|
367
|
+
// A server's output goes through a cell of its own: a later watch cycle
|
|
368
|
+
// that keeps the server points it at that cycle's bus (`RunOptions.keep`).
|
|
369
|
+
const serverOuts = new Map<string, { bus: EventBus }>()
|
|
370
|
+
const serverLog = (id: string): Logger => {
|
|
371
|
+
const out = { bus }
|
|
372
|
+
serverOuts.set(id, out)
|
|
373
|
+
return {
|
|
374
|
+
...log,
|
|
375
|
+
taskStdout: (node, chunk) => out.bus.emit({ kind: 'task:stdout', node, chunk }),
|
|
376
|
+
taskStderr: (node, chunk) => out.bus.emit({ kind: 'task:stderr', node, chunk }),
|
|
377
|
+
}
|
|
378
|
+
}
|
|
361
379
|
|
|
362
380
|
const prepared = await prepareRun(options, log)
|
|
363
381
|
mark('plugin stages')
|
|
@@ -469,6 +487,20 @@ async function runOnBus(
|
|
|
469
487
|
workspaceProjectCount,
|
|
470
488
|
} = prepared
|
|
471
489
|
const concurrency = options.concurrency ?? workspaceConfig?.concurrency ?? machineParallelism()
|
|
490
|
+
// An earlier cycle's servers: the ones this graph starts again unchanged
|
|
491
|
+
// stay up; the others go now, before anything binds their ports. A stop
|
|
492
|
+
// that lands meanwhile ends the run here, having run nothing: an aborted
|
|
493
|
+
// graph printed a `not run` row and a footer above `stopped` (WD-22).
|
|
494
|
+
const heldBefore = takeHeldServers(options.keep?.servers, nodes, options.forwardArgs)
|
|
495
|
+
if (heldBefore.stale.length > 0) {
|
|
496
|
+
await terminateChildren(() => heldBefore.stale)
|
|
497
|
+
if (options.signal?.aborted === true) {
|
|
498
|
+
disposePlugins?.()
|
|
499
|
+
await teardown()
|
|
500
|
+
prepared.cache.close()
|
|
501
|
+
return { ok: true, outcomes: [] }
|
|
502
|
+
}
|
|
503
|
+
}
|
|
472
504
|
|
|
473
505
|
// Resolved ONCE per run, in declaration order, the local executor last.
|
|
474
506
|
// A broken factory aborts here, before any task starts.
|
|
@@ -968,7 +1000,7 @@ async function runOnBus(
|
|
|
968
1000
|
cache,
|
|
969
1001
|
cachePolicy: policy,
|
|
970
1002
|
forwardArgs: options.forwardArgs,
|
|
971
|
-
log,
|
|
1003
|
+
log: node.config.exec?.persistent !== undefined ? serverLog(node.id) : log,
|
|
972
1004
|
executor: placements.executors.get(node.id) ?? UNPLACED_EXECUTOR,
|
|
973
1005
|
deferred: deferredOutputs,
|
|
974
1006
|
nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
|
|
@@ -1041,6 +1073,25 @@ async function runOnBus(
|
|
|
1041
1073
|
// History is observability: an unreadable one judges nothing.
|
|
1042
1074
|
}
|
|
1043
1075
|
}
|
|
1076
|
+
const keepServer = (node: TaskNode): TaskOutcome | undefined => {
|
|
1077
|
+
const server = heldBefore.kept.get(node.id)
|
|
1078
|
+
if (server === undefined) return undefined
|
|
1079
|
+
heldBefore.kept.delete(node.id)
|
|
1080
|
+
if (hasEnded(server.child)) return undefined
|
|
1081
|
+
server.handOver()
|
|
1082
|
+
server.out.bus = bus
|
|
1083
|
+
serverOuts.set(node.id, server.out)
|
|
1084
|
+
persistentRegistry.set(node.id, server.child)
|
|
1085
|
+
const at = process.hrtime.bigint() - runStartHrTimeNs
|
|
1086
|
+
return {
|
|
1087
|
+
node,
|
|
1088
|
+
status: 'success',
|
|
1089
|
+
exitCode: 0,
|
|
1090
|
+
durationMs: 0,
|
|
1091
|
+
wallclockStartNs: at,
|
|
1092
|
+
wallclockEndNs: at,
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1044
1095
|
const outcomes = await runGraph({
|
|
1045
1096
|
nodes,
|
|
1046
1097
|
concurrency,
|
|
@@ -1063,7 +1114,12 @@ async function runOnBus(
|
|
|
1063
1114
|
narrowDemand(o.node.id)
|
|
1064
1115
|
},
|
|
1065
1116
|
onError: (node, line) => log.taskStderr(node, line),
|
|
1066
|
-
execute: (node, upstream) =>
|
|
1117
|
+
execute: (node, upstream) => {
|
|
1118
|
+
const kept = keepServer(node)
|
|
1119
|
+
return kept !== undefined
|
|
1120
|
+
? Promise.resolve(kept)
|
|
1121
|
+
: executeWithDedup(node, keyUpstream(node, upstream))
|
|
1122
|
+
},
|
|
1067
1123
|
// A `schedule` plugin's weights; the scheduler keeps its structural
|
|
1068
1124
|
// baseline as the tie-break. Empty map → baseline only.
|
|
1069
1125
|
...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
|
|
@@ -1384,11 +1440,22 @@ async function runOnBus(
|
|
|
1384
1440
|
// keep-alive says it: `vx watch` sat on "watching" over a dead dev
|
|
1385
1441
|
// server. The holder's own stop() is not such a death.
|
|
1386
1442
|
let stopping = false
|
|
1443
|
+
// Taken by a later run (`RunOptions.keep`): kept or stopped there.
|
|
1444
|
+
const handed = new Set<ReturnType<typeof Bun.spawn>>()
|
|
1445
|
+
const servers = new Map<string, HeldServer>()
|
|
1387
1446
|
keepAlive.nodes.forEach((n, i) => {
|
|
1447
|
+
const child = held[i]!
|
|
1448
|
+
servers.set(n.id, {
|
|
1449
|
+
child,
|
|
1450
|
+
config: serverConfig(n, options.forwardArgs),
|
|
1451
|
+
out: serverOuts.get(n.id) ?? { bus },
|
|
1452
|
+
handOver: () => void handed.add(child),
|
|
1453
|
+
})
|
|
1388
1454
|
// One that died during the graph was said then.
|
|
1389
|
-
if (hasEnded(
|
|
1390
|
-
void
|
|
1391
|
-
if (!stopping &&
|
|
1455
|
+
if (hasEnded(child)) return
|
|
1456
|
+
void child.exited.then((code) => {
|
|
1457
|
+
if (!stopping && !handed.has(child) && code !== 0)
|
|
1458
|
+
log.status(`vx: ${n.id} exited with code ${code}`)
|
|
1392
1459
|
})
|
|
1393
1460
|
})
|
|
1394
1461
|
return {
|
|
@@ -1396,9 +1463,10 @@ async function runOnBus(
|
|
|
1396
1463
|
outcomes: list,
|
|
1397
1464
|
persistent: {
|
|
1398
1465
|
ids: keepAlive.nodes.map((n) => n.id),
|
|
1466
|
+
servers,
|
|
1399
1467
|
stop: (signal) => {
|
|
1400
1468
|
stopping = true
|
|
1401
|
-
return terminateChildren(() => held, signal)
|
|
1469
|
+
return terminateChildren(() => held.filter((c) => !handed.has(c)), signal)
|
|
1402
1470
|
},
|
|
1403
1471
|
},
|
|
1404
1472
|
}
|