@vzn/vx 0.0.619 → 0.0.620

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.619",
3
+ "version": "0.0.620",
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.619",
25
- "@vzn/vx-linux-arm64": "0.0.619",
26
- "@vzn/vx-darwin-x64": "0.0.619",
27
- "@vzn/vx-darwin-arm64": "0.0.619"
24
+ "@vzn/vx-linux-x64": "0.0.620",
25
+ "@vzn/vx-linux-arm64": "0.0.620",
26
+ "@vzn/vx-darwin-x64": "0.0.620",
27
+ "@vzn/vx-darwin-arm64": "0.0.620"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -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 the cycle before started was running — a persistent task rewrites it. Add it to .gitignore (a git-ignored path never starts a cycle); until then every write restarts the server.\n`
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; the cycle that replaces it
413
- // stops it first, so the new one never meets the old one's port.
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
- current = watchCycle(opts, stop, interruptIfWaiting)
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
+ }
@@ -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 { hasEnded, selectKeepAlive, shutdownPersistent } from './persistent.js'
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) => executeWithDedup(node, keyUpstream(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(held[i]!)) return
1390
- void held[i]!.exited.then((code) => {
1391
- if (!stopping && code !== 0) log.status(`vx: ${n.id} exited with code ${code}`)
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
  }