@vzn/vx 0.0.638 → 0.0.639

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.
Files changed (103) hide show
  1. package/docs/caching.md +44 -15
  2. package/docs/cli.md +168 -345
  3. package/docs/execution.md +18 -43
  4. package/docs/features.md +7 -10
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +47 -49
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/skills/vx/SKILL.md +6 -4
  10. package/src/cache/cache.ts +40 -10
  11. package/src/cache/git-inputs.ts +6 -0
  12. package/src/cache/index.ts +2 -2
  13. package/src/cache/layer.ts +25 -11
  14. package/src/cache/layered-cache.ts +108 -315
  15. package/src/cache/plugin-cache.ts +2 -10
  16. package/src/cache/remote-lane.ts +383 -0
  17. package/src/cache/run-history.ts +24 -8
  18. package/src/cache/run-store.ts +4 -4
  19. package/src/cache/schema.ts +6 -4
  20. package/src/cache/scoped-cache.ts +7 -0
  21. package/src/cli/completions.ts +2 -2
  22. package/src/cli/foreign-flags.ts +10 -22
  23. package/src/cli/help.ts +6 -31
  24. package/src/cli/info.ts +2 -1
  25. package/src/cli/last.ts +6 -3
  26. package/src/cli/plan-format.ts +5 -58
  27. package/src/cli/plugin-commands.ts +2 -2
  28. package/src/cli/run.ts +23 -185
  29. package/src/cli/show.ts +6 -0
  30. package/src/cli/upgrade.ts +123 -39
  31. package/src/cli/watch-cycle.ts +2 -1
  32. package/src/cli/watch.ts +29 -14
  33. package/src/cli/why.ts +4 -4
  34. package/src/config.ts +0 -6
  35. package/src/exec/executor.ts +1 -1
  36. package/src/exec/sandbox-runtime.ts +1 -1
  37. package/src/exec/sandbox-violations.ts +3 -1
  38. package/src/graph/index.ts +9 -1
  39. package/src/graph/priorities.ts +1 -1
  40. package/src/graph/scheduler.ts +4 -16
  41. package/src/graph/task-graph.ts +87 -17
  42. package/src/graph/task-status.ts +95 -0
  43. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  44. package/src/{orchestrator → history}/history.ts +1 -1
  45. package/src/history/index.ts +48 -0
  46. package/src/{orchestrator → history}/metrics.ts +7 -20
  47. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  48. package/src/index.ts +10 -28
  49. package/src/orchestrator/doctor.ts +1 -1
  50. package/src/orchestrator/execute-args.ts +80 -0
  51. package/src/orchestrator/execute-task.ts +21 -386
  52. package/src/orchestrator/fingerprint-watch.ts +2 -2
  53. package/src/orchestrator/index.ts +1 -73
  54. package/src/orchestrator/miss-save.ts +33 -3
  55. package/src/orchestrator/options.ts +1 -36
  56. package/src/orchestrator/output-host.ts +4 -3
  57. package/src/orchestrator/persistent-task.ts +321 -0
  58. package/src/orchestrator/persistent.ts +130 -3
  59. package/src/orchestrator/placement.ts +3 -3
  60. package/src/orchestrator/plan.ts +18 -5
  61. package/src/orchestrator/plugin-host.ts +67 -47
  62. package/src/orchestrator/plugin.ts +6 -6
  63. package/src/orchestrator/prepare.ts +28 -29
  64. package/src/orchestrator/remote-prefetch.ts +14 -1
  65. package/src/orchestrator/run-artifacts.ts +5 -177
  66. package/src/orchestrator/run-context.ts +55 -0
  67. package/src/orchestrator/run-failures.ts +15 -3
  68. package/src/orchestrator/run-records.ts +35 -3
  69. package/src/orchestrator/run-report.ts +81 -179
  70. package/src/orchestrator/run-settings.ts +1 -16
  71. package/src/orchestrator/run-stop.ts +153 -0
  72. package/src/orchestrator/run.ts +260 -601
  73. package/src/orchestrator/sandbox-fix.ts +0 -16
  74. package/src/orchestrator/signals.ts +14 -6
  75. package/src/orchestrator/stable-keys.ts +107 -3
  76. package/src/orchestrator/steps.ts +50 -0
  77. package/src/orchestrator/task-hash.ts +3 -16
  78. package/src/orchestrator/task-log-buffer.ts +1 -1
  79. package/src/orchestrator/task-process.ts +94 -0
  80. package/src/orchestrator/telemetry-host.ts +1 -1
  81. package/src/orchestrator/telemetry.ts +5 -82
  82. package/src/{orchestrator → report}/events.ts +41 -8
  83. package/src/{orchestrator → report}/forecast.ts +35 -2
  84. package/src/{orchestrator → report}/framed-output.ts +39 -33
  85. package/src/report/index.ts +36 -0
  86. package/src/{orchestrator → report}/logger.ts +158 -20
  87. package/src/{orchestrator → report}/status-line.ts +84 -7
  88. package/src/{orchestrator → report}/summary.ts +126 -35
  89. package/src/report/tally.ts +83 -0
  90. package/src/settings.ts +1 -22
  91. package/src/util/colors.ts +44 -4
  92. package/src/util/git-ignored.ts +143 -0
  93. package/src/util/index.ts +19 -4
  94. package/src/util/report.ts +13 -5
  95. package/src/util/settle.ts +14 -39
  96. package/src/util/tail.ts +1 -1
  97. package/src/util/ui.ts +17 -6
  98. package/schemas/summary.json +0 -133
  99. package/src/cache/chained-cache.ts +0 -280
  100. package/src/orchestrator/tally.ts +0 -112
  101. /package/src/{orchestrator → report}/path-links.ts +0 -0
  102. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  103. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -25,8 +25,8 @@ import {
25
25
  taskGlob,
26
26
  wholeSubtreePrefixes,
27
27
  } from '../util/index.js'
28
- import type { Logger } from './logger.js'
29
- import type { TaskInputComponent } from './task-hash.js'
28
+ import { type Logger, warnOf } from '../report/index.js'
29
+ import type { TaskInputComponent } from '../cache/index.js'
30
30
 
31
31
  /** A snapshot request for `recordOutputDirs`, taken at run end. */
32
32
  export interface OutputDirSnapshot {
@@ -61,6 +61,36 @@ export function entryHolds(
61
61
  }
62
62
  }
63
63
 
64
+ /**
65
+ * The run-end output-directory snapshots, taken once the directories are
66
+ * old enough for the snapshot's racy window. Each walk is held to its
67
+ * entry's rows (item 1087), read in one batch: read per snapshot, each read
68
+ * also flushed the snapshots before it, one transaction apiece (+110 ms at
69
+ * 1,000). A few walks at a time: each is an lstat + readdir per prefix; the
70
+ * rows land together at close, in one transaction.
71
+ */
72
+ export async function recordOutputDirSnapshots(
73
+ cache: CacheLayer,
74
+ snapshots: readonly OutputDirSnapshot[],
75
+ ): Promise<void> {
76
+ const rowsOf = new Map<string, ReadonlyArray<{ path: string }>>()
77
+ for (let i = 0; i < snapshots.length; i += 900) {
78
+ const batch = cache.loadOutputFilesBatch(snapshots.slice(i, i + 900).map((s) => s.hash))
79
+ for (const [hash, rows] of batch) rowsOf.set(hash, rows)
80
+ }
81
+ for (let i = 0; i < snapshots.length; i += 32) {
82
+ await Promise.all(
83
+ snapshots
84
+ .slice(i, i + 32)
85
+ .map((s) =>
86
+ cache.recordOutputDirs?.(s.hash, s.projectDir, s.prefixes, (files) =>
87
+ s.holds(files, rowsOf.get(s.hash) ?? []),
88
+ ),
89
+ ),
90
+ )
91
+ }
92
+ }
93
+
64
94
  export interface SaveMissArgs {
65
95
  node: TaskNode
66
96
  hash: string
@@ -153,7 +183,7 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
153
183
  // A `workspaceFiles` directory linked out of the workspace drops every
154
184
  // file (M-65); a project one is refused by the resolver (X-88).
155
185
  const linkedOut = outputDirLinkedOut(a.workspaceRoot, a.wsOutputs)
156
- log.status(
186
+ warnOf(log)(
157
187
  `[vx] ${node.id}: cache.outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
158
188
  `an empty artifact is saved; a later hit restores nothing` +
159
189
  (linkedOut !== undefined
@@ -6,10 +6,8 @@ import type { CacheFlags, CachePolicy, RemoteCacheLayer } from '../cache/index.j
6
6
  import type { ContinueMode } from '../graph/index.js'
7
7
  import type { TaskOutcome } from '../graph/index.js'
8
8
  import type { AffectedReason } from './affected-tasks.js'
9
- import type { EventBus } from './events.js'
9
+ import type { EventBus, Logger } from '../report/index.js'
10
10
  import type { TelemetrySink } from './telemetry.js'
11
- import type { Logger } from './logger.js'
12
- import type { RunSummaryJson } from './run-artifacts.js'
13
11
  import type { AffectedChanges, ProjectEntry, ProjectMeta } from '../workspace/index.js'
14
12
  import type { RunSettings } from '../config.js'
15
13
 
@@ -164,12 +162,6 @@ export interface RunOptions {
164
162
  branch?: string
165
163
  /** `run.schedule` from its flag; unset, `VX_RUN_SCHEDULE`, then the workspace's. */
166
164
  schedule?: NonNullable<RunSettings['schedule']>
167
- /** `run.killGrace` in ms from its flag; unset, `VX_RUN_KILL_GRACE`, then the workspace's. */
168
- killGrace?: number
169
- /** `run.teardownTimeout` in ms from its flag; unset, `VX_RUN_TEARDOWN_TIMEOUT`, then the workspace's. */
170
- teardownTimeout?: number
171
- /** `run.readyNotice` in ms from its flag; unset, `VX_RUN_READY_NOTICE`, then the workspace's. */
172
- readyNotice?: number
173
165
  /**
174
166
  * Filter `dependsOn` expansion. `'all'` drops every edge (just the
175
167
  * requested task runs). A string array drops only those task names
@@ -178,25 +170,6 @@ export interface RunOptions {
178
170
  excludeDependencies?: 'all' | readonly string[]
179
171
  /** Forwarded to the last step of each task's exec array (shell-quoted). */
180
172
  forwardArgs?: readonly string[]
181
- /**
182
- * If set, write a per-run JSON summary at end of run. Empty string
183
- * picks the default path `<cacheDir>/runs/<run_id>.json`; anything
184
- * else is treated as the literal file path (cwd-relative).
185
- */
186
- summarize?: string
187
- /**
188
- * `vx run --format json`: the terminal renderer writes stderr, and the
189
- * run hands back `RunSummary.json`, the `--summarize` document, for the
190
- * caller to print on stdout.
191
- */
192
- json?: boolean
193
- /**
194
- * Text printed just above the run's footer, given the outcomes the
195
- * footer counts, after the `summaryTable`: nothing prints below the
196
- * footer (owner), so a caller's end-of-run report (`--report`) rides
197
- * here. Not called on a refused run.
198
- */
199
- beforeFooter?: (outcomes: readonly TaskOutcome[], ok: boolean) => string
200
173
  /**
201
174
  * `false`: no footer, live or printed, and no blank line opening the
202
175
  * task log; the run is its task rows (`vx watch`, which frames each
@@ -283,12 +256,6 @@ export interface RunOptions {
283
256
  * A stateless `vx run` passes none and is byte-identical to before.
284
257
  */
285
258
  inflight?: Map<string, Promise<void>>
286
- /**
287
- * `--tag k=v` pairs (Tier-3). Persisted on the run's `invocations`
288
- * row (JSON object) so dashboards can filter runs by label, a secret
289
- * value masked (L-38). CLI parsing is the CLI's job.
290
- */
291
- tags?: Record<string, string>
292
259
  /**
293
260
  * Additional observe-only telemetry sinks, merged with whatever the
294
261
  * workspace plugins contribute. The embedder seam: a host executing
@@ -337,8 +304,6 @@ export interface RunSummary {
337
304
  * caller prints it; `vx run` writes it to stderr.
338
305
  */
339
306
  refused?: string
340
- /** The `--summarize` document, when `RunOptions.json` asked for it. */
341
- json?: RunSummaryJson
342
307
  }
343
308
 
344
309
  export interface HeldPersistent {
@@ -2,12 +2,12 @@
2
2
  // to telemetry-host.ts. With no plugin declaring the hook nothing here runs,
3
3
  // and core's renderer is the run's, byte for byte.
4
4
 
5
- import { terminalSubscriber, type RunEventSubscriber } from './events.js'
6
- import type { Logger } from './logger.js'
5
+ import { terminalSubscriber, type RunEventSubscriber } from '../report/index.js'
6
+ import type { Logger } from '../report/index.js'
7
7
  import type { OutputContext, VxPlugin } from './plugin.js'
8
8
 
9
9
  const REQUIRED = ['status', 'taskStdout', 'taskStderr', 'taskComplete'] as const
10
- const OPTIONAL = ['runStart', 'taskStart', 'runEnd'] as const
10
+ const OPTIONAL = ['runStart', 'taskStart', 'runEnd', 'stepStart', 'stepEnd', 'runDone'] as const
11
11
 
12
12
  /** Reject anything that is not Logger-shaped, naming what is missing. */
13
13
  function checkLogger(value: unknown): Logger {
@@ -64,6 +64,7 @@ export function guardedOutput(
64
64
  failed = true
65
65
  fallback({
66
66
  kind: 'run:status',
67
+ warn: true,
67
68
  line: `[vx] plugin '${claimed.owner}' output failed: ${err instanceof Error ? err.message : String(err)}; vx renders the rest of the run`,
68
69
  })
69
70
  fallback(event)
@@ -0,0 +1,321 @@
1
+ // A persistent task (a dev server, a watcher): keyed like an uncached task,
2
+ // spawned, and held until `readyWhen` matches. Never cached and never routed
3
+ // through an executor. Moved out of execute-task.ts as pure code motion.
4
+
5
+ import type { ExecConfig } from '../config.js'
6
+ import {
7
+ PersistentReadyError,
8
+ releaseBridges,
9
+ runPersistent,
10
+ signalExitCode,
11
+ withForwardArgs,
12
+ wrapSandboxedCommand,
13
+ } from '../exec/index.js'
14
+ import type { TaskOutcome } from '../graph/index.js'
15
+ import { maskedEmitter, secretMask, testTimerMs } from '../util/index.js'
16
+ import { warnOf } from '../report/index.js'
17
+ import type { ExecuteArgs } from './execute-task.js'
18
+ import {
19
+ commandWriteReach,
20
+ mayWriteFingerprint,
21
+ type Placeholder,
22
+ placeholderSweeper,
23
+ sandboxRequestFor,
24
+ sweepPlaceholders,
25
+ undeclaredWriteReach,
26
+ untouchedPlaceholderLine,
27
+ } from './sandbox-request.js'
28
+ import { forwardedSignal, terminateChildren } from './signals.js'
29
+ import { computeTaskHash } from './task-hash.js'
30
+ import { forgetUndeclaredWrites, isAborted, taskEnv, taskTimeoutMs } from './task-process.js'
31
+
32
+ /** How long a server may take to match `readyWhen` before vx says it waits. */
33
+ const READY_NOTICE_MS = 10_000
34
+
35
+ /**
36
+ * Persistent task: dev server / file watcher / daemon. Spawn, wait
37
+ * for ready (regex match or immediate), stash the subprocess in the
38
+ * orchestrator-owned registry, return success. The orchestrator
39
+ * SIGTERMs the registry at end-of-run.
40
+ *
41
+ * Never reads or writes the cache — the project loader rejects
42
+ * `cache + persistent` at config-load time, so by the time we get
43
+ * here, `cache` is guaranteed undefined.
44
+ *
45
+ * Never routed through an executor: a persistent task is local by
46
+ * construction (its port lives on this machine).
47
+ */
48
+ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
49
+ const { node, log } = args
50
+ // Type narrowing: the dispatcher checks `exec.persistent` before
51
+ // calling us, so `exec` and `exec.persistent` are both present.
52
+ const step = node.config.exec as ExecConfig & {
53
+ persistent: NonNullable<ExecConfig['persistent']>
54
+ }
55
+ const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
56
+ const env = taskEnv(node, step, args.workspaceRoot)
57
+ const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
58
+ // Keyed like an uncached task (its whole project), so a cached dependant
59
+ // behind a dev server re-runs when the server's sources change; with no
60
+ // key the dependant folded nothing of it and replayed a stale e2e (A-17).
61
+ const hash =
62
+ args.noDependants === true
63
+ ? undefined
64
+ : (args.upfrontKey ??
65
+ (await computeTaskHash({
66
+ node,
67
+ upstream: args.upstream,
68
+ workspaceRoot: args.workspaceRoot,
69
+ cache: args.cache,
70
+ forwardArgs: args.forwardArgs,
71
+ nestedProjectDirs: args.nestedProjectDirs,
72
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
73
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
74
+ })))
75
+
76
+ // The args after `--` reach a server as they reach any task. A readyWhen
77
+ // server once got none — "so the matcher sees the unmodified output",
78
+ // though the args change the command, not what the matcher reads — and
79
+ // `vx run dev -- --port 4000` on the documented Vite task dropped the
80
+ // port in silence (item 1060).
81
+ const plainCommand = withForwardArgs(step.command, effectiveForwardArgs)
82
+ // A persistent task declaring `exec.sandbox` runs inside it like any
83
+ // other: the same grants, the same walls. What it cannot have is the
84
+ // violation REPORT — that reads the trace after the child exits, and a
85
+ // server exits when the run tears it down. Enforced, not reported.
86
+ // (Until 2026-09-09 the block was accepted and silently ignored.)
87
+ let command = plainCommand
88
+ let bridgeTag: string | undefined
89
+ let signalChannel = false
90
+ // The empty files the request pre-created for a literal write grant; a
91
+ // server that never writes one gets it taken back when it exits, and a
92
+ // server that dies before readiness is told the directory spelling — the
93
+ // same trap and the same answer as a one-shot task's (sandbox-request.ts).
94
+ let placeholders: Placeholder[] = []
95
+ if (step.sandbox !== undefined) {
96
+ await args.armSandbox?.()
97
+ // A persistent task never declares `cache` (the loader refuses it), so
98
+ // it keeps the whole linked grant: no key of its can be stale.
99
+ const sb = await sandboxRequestFor(
100
+ node,
101
+ step.sandbox,
102
+ args.workspaceRoot,
103
+ undefined,
104
+ args.nestedProjectDirs,
105
+ args.cacheDir,
106
+ )
107
+ placeholders = sb.placeholders
108
+ // A wrap that refuses (a port the host holds) leaves the server unspawned
109
+ // and the placeholders vx made for it in the project for every later run.
110
+ const wrapped = await wrapSandboxedCommand({
111
+ command: plainCommand,
112
+ cwd: node.projectDir,
113
+ env,
114
+ ...sb.sandbox,
115
+ server: true,
116
+ }).catch(async (err: unknown) => {
117
+ await sweepPlaceholders(placeholders)
118
+ throw err
119
+ })
120
+ command = wrapped.wrapped
121
+ bridgeTag = wrapped.tag
122
+ signalChannel = wrapped.forwardsSignals
123
+ }
124
+ // A server's output masked as a one-shot task's is (L-11); `readyWhen`
125
+ // is matched on the raw chunks, before this. Its held tail is flushed by
126
+ // the idle timer: a server never ends on the way to a flush.
127
+ const serverSecrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
128
+ const serverOut = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStdout(node, t))
129
+ const serverErr = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStderr(node, t))
130
+ // A server writes until the run stops it: its window stays open.
131
+ args.commandWindows?.open(node.id, node.projectDir, commandWriteReach(node, args.workspaceRoot))
132
+ const persistentOpts: Parameters<typeof runPersistent>[0] = {
133
+ command,
134
+ cwd: node.projectDir,
135
+ env,
136
+ onStdout: serverOut ? (chunk) => serverOut.push(chunk) : (chunk) => log.taskStdout(node, chunk),
137
+ onStderr: serverErr ? (chunk) => serverErr.push(chunk) : (chunk) => log.taskStderr(node, chunk),
138
+ ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
139
+ ...(args.track !== undefined
140
+ ? { onSpawn: (pid: number) => void args.track!(node.id, pid) }
141
+ : {}),
142
+ ...(signalChannel ? { signalChannel } : {}),
143
+ ...(args.terminal === true ? { terminal: true } : {}),
144
+ }
145
+ if (step.persistent.readyWhen !== undefined) {
146
+ persistentOpts.readyWhen = step.persistent.readyWhen
147
+ }
148
+ // For a persistent task the timeout bounds the readiness wait. Per-task
149
+ // `exec.timeout` wins; else the run-level default (env/workspace/--timeout).
150
+ const effectiveTimeout = taskTimeoutMs(step) ?? args.timeout
151
+ if (effectiveTimeout !== undefined) {
152
+ persistentOpts.timeoutMs = effectiveTimeout
153
+ }
154
+
155
+ // A stop that landed during the awaits above (the key, the sandbox's
156
+ // arming, request and wrap) leaves nothing to kill yet: spawned now, the
157
+ // server came up after the teardown and held the run's exit 7 s.
158
+ if (args.stopSignal?.aborted === true) {
159
+ if (bridgeTag !== undefined) releaseBridges(bridgeTag)
160
+ await placeholderSweeper(placeholders)()
161
+ return {
162
+ node,
163
+ status: 'aborted',
164
+ // The signal a spawned server would have been sent (a hang-up forwards SIGTERM).
165
+ exitCode: signalExitCode(forwardedSignal(args.stopSignal.reason)),
166
+ durationMs: 0,
167
+ wallclockStartNs,
168
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
169
+ }
170
+ }
171
+ const spawn = runPersistent(persistentOpts)
172
+ // The host side of a port bridge lives exactly as long as the server:
173
+ // released on the child's exit, whether the run tore it down or it died.
174
+ // A placeholder the server never wrote goes back the same way.
175
+ // ONE sweep, shared by the exit handler and the readiness failure below
176
+ // — whichever asks second must get the SAME list. A second sweep cannot
177
+ // produce it: the first one's `rm` already happened, so the file is gone
178
+ // and `sweepPlaceholders` skips it. Collecting both lists and unioning
179
+ // them does not close that, because the union only covers an exit
180
+ // handler that FINISHED; one still between its `rm` and its return has
181
+ // published nothing yet, and the readiness path then reports no
182
+ // placeholder at all. The failure then says only "File exists", which is
183
+ // the message the hint exists to explain (seen under the gate's parallel
184
+ // load, and reproduced deterministically by delaying each side, item 450).
185
+ const sweptUntouched = placeholderSweeper(placeholders)
186
+ if (bridgeTag !== undefined || placeholders.length > 0) {
187
+ const tag = bridgeTag
188
+ const onExit = async (): Promise<void> => {
189
+ if (tag !== undefined) releaseBridges(tag)
190
+ await sweptUntouched()
191
+ }
192
+ // A spawn that failed has no child to wait on: release now, or the
193
+ // tag would hold the sandbox's reset for the rest of the process.
194
+ if (spawn.child === undefined) void onExit()
195
+ else void spawn.child.exited.then(onExit, onExit)
196
+ }
197
+ // Said once if readiness is slow: a dependency's output is hidden unless
198
+ // it fails, and with no `exec.timeout` the wait never ends, so a run
199
+ // whose `readyWhen` never matched showed nothing at all.
200
+ const readyWhen = step.persistent.readyWhen
201
+ // How long a server may take to match `readyWhen` before vx says it waits.
202
+ const noticeMs = testTimerMs('READY_NOTICE', READY_NOTICE_MS)
203
+ const notice =
204
+ readyWhen === undefined
205
+ ? undefined
206
+ : setTimeout(() => {
207
+ if (isAborted(args.stopSignal)) return
208
+ const after = noticeMs < 1000 ? `${noticeMs} ms` : `${noticeMs / 1000} s`
209
+ const unbounded = effectiveTimeout === undefined ? ', with no exec.timeout' : ''
210
+ warnOf(log)(
211
+ `vx: ${node.id} not ready after ${after}: waiting for a line matching /${serverSecrets?.mask(readyWhen) ?? readyWhen}/ (readyWhen)${unbounded}`,
212
+ )
213
+ }, noticeMs)
214
+ if (notice !== undefined) {
215
+ const quiet = (): void => clearTimeout(notice)
216
+ void spawn.ready.then(quiet, quiet)
217
+ }
218
+ try {
219
+ await spawn.ready
220
+ } catch (err) {
221
+ // A shell that exited before ready can leave its group running
222
+ // (`server & …; exit`): never registered, so no teardown reached it,
223
+ // and it held its port against the next `vx watch` cycle's server.
224
+ if (err instanceof PersistentReadyError && err.reason === 'exited')
225
+ await terminateChildren(() => [spawn.child])
226
+ // A server the run's stop killed while it started is aborted, as any
227
+ // task the stop kills (item 962): it read `failed (never ready:
228
+ // exited, exit 130)` with a recap after every Ctrl-C (C-62). The stop
229
+ // aborts before it kills, so it is set by the time the child is gone.
230
+ // Read through a call: the early return above narrows `aborted` to
231
+ // false, but the stop can land during `spawn.ready`.
232
+ if (isAborted(args.stopSignal)) {
233
+ return {
234
+ node,
235
+ status: 'aborted',
236
+ exitCode: err instanceof PersistentReadyError ? (err.exitCode ?? 1) : 1,
237
+ durationMs: spawn.readyMs(),
238
+ wallclockStartNs,
239
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
240
+ }
241
+ }
242
+ const message = err instanceof Error ? err.message : String(err)
243
+ // The task's OWN stream, not the process's: the frame is where a
244
+ // reader looks for why a task failed, and a run with a custom logger
245
+ // (an embedder) never saw a bare stderr write at all.
246
+ log.taskStderr(node, `\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
247
+ // A server is never traced, so the sandbox's refusals reach no report:
248
+ // a dev server that died on a file outside its grants read only as the
249
+ // tool's own "not found" (2026-10-03). Said for a sandboxed server that
250
+ // exited failing, which is the shape a refusal takes.
251
+ if (
252
+ step.sandbox !== undefined &&
253
+ err instanceof PersistentReadyError &&
254
+ err.reason === 'exited' &&
255
+ err.exitCode !== 0
256
+ ) {
257
+ log.taskStderr(
258
+ node,
259
+ `[vx] ${node.id} ran in the sandbox, which reports nothing for a server: a path outside ` +
260
+ `its grants reads as missing (ENOENT), a refused write as read-only (EROFS). Check ` +
261
+ `exec.sandbox.allow, or run the command as a one-shot sandboxed task to see what it was refused.\n`,
262
+ )
263
+ }
264
+ // The server is dead or being torn down, so the sweep on its exit may
265
+ // already be running: ask the shared one rather than starting a second.
266
+ for (const p of await sweptUntouched()) {
267
+ log.taskStderr(node, `${untouchedPlaceholderLine(node.projectDir, p)}\n`)
268
+ }
269
+ // The reason rides the outcome (every label reads it), and a child that
270
+ // exited before ready keeps its own exit code rather than a made-up 1.
271
+ // One the readiness timeout is killing reports the signal's, as an
272
+ // ordinary timeout does (X-24).
273
+ // A readiness timeout rejects once the group is gone (runner.ts), so a
274
+ // server that ignored the TERM holds no port into the next `vx watch`
275
+ // cycle, and a second signal here would cut a one-shot handler short.
276
+ const ready = err instanceof PersistentReadyError ? err : undefined
277
+ return {
278
+ node,
279
+ status: 'failed',
280
+ // A server that traps the TERM and exits 0 still timed out, as a
281
+ // one-shot task that does so reports 143 (X-116).
282
+ exitCode:
283
+ ready?.reason === 'timeout'
284
+ ? (await spawn.child.exited) || signalExitCode('SIGTERM')
285
+ : (ready?.exitCode ?? 1),
286
+ durationMs: spawn.readyMs(),
287
+ ...(ready !== undefined ? { notReady: ready.reason } : {}),
288
+ wallclockStartNs,
289
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
290
+ }
291
+ }
292
+
293
+ // Ready only once the stop had landed: a trap that prints the marker on
294
+ // the way down, or a race with the signal. The stop is killing it (it
295
+ // is in `liveChildren`), and it served nobody, as a one-shot that exits
296
+ // 0 on the stop is aborted (item 962).
297
+ if (isAborted(args.stopSignal)) {
298
+ return {
299
+ node,
300
+ status: 'aborted',
301
+ exitCode: signalExitCode(forwardedSignal(args.stopSignal!.reason)),
302
+ durationMs: spawn.readyMs(),
303
+ wallclockStartNs,
304
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
305
+ }
306
+ }
307
+ args.persistentRegistry?.set(node.id, spawn.child)
308
+ forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
309
+ if (mayWriteFingerprint(node, args.workspaceRoot, args.fingerprintWatch?.names ?? [])) {
310
+ args.fingerprintWatch?.wrote()
311
+ }
312
+ return {
313
+ node,
314
+ status: 'success',
315
+ exitCode: 0,
316
+ durationMs: spawn.readyMs(),
317
+ ...(hash !== undefined ? { hash } : {}),
318
+ wallclockStartNs,
319
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
320
+ }
321
+ }
@@ -3,10 +3,11 @@
3
3
  // ownership moves to the run's registry, and this is the one place that
4
4
  // decides which of them outlive the graph and how the rest go down.
5
5
 
6
- import { isGroupTask, type TaskNode } from '../graph/index.js'
6
+ import { isGroupTask, isPassStatus, type TaskNode, type TaskOutcome } from '../graph/index.js'
7
7
  import { killGraceMs } from '../util/index.js'
8
- import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
9
- import type { HeldServer } from './options.js'
8
+ import { holdGroups, killTree, signalExitCode, untilGroupsGone } from '../exec/index.js'
9
+ import type { HeldPersistent, HeldServer } from './options.js'
10
+ import { terminateChildren } from './signals.js'
10
11
 
11
12
  type Child = ReturnType<typeof Bun.spawn>
12
13
 
@@ -152,3 +153,129 @@ export function takeHeldServers(
152
153
  }
153
154
  return { kept, stale }
154
155
  }
156
+
157
+ /**
158
+ * The kept server whose exit ends a foreground session, and its code. A
159
+ * server kept only as a dependency that exits 0 is a daemon that forked
160
+ * and returned (schema.md), not the end of the session: a
161
+ * `db: 'docker compose up -d'` under `app#dev` stopped the dev server
162
+ * right after the summary (WD-3). It ends the wait only as the last.
163
+ */
164
+ export function sessionEnd(keepAlive: KeepAlive): Promise<{ code: number; i: number }> {
165
+ return new Promise((resolve) => {
166
+ let left = keepAlive.children.length
167
+ keepAlive.children.forEach((c, i) => {
168
+ void c.exited.then((code) => {
169
+ left--
170
+ const n = keepAlive.nodes[i]!
171
+ if (code !== 0 || n.requested || n.surfaced === true || left === 0) resolve({ code, i })
172
+ })
173
+ })
174
+ })
175
+ }
176
+
177
+ export interface HoldArgs {
178
+ forwardArgs: readonly string[] | undefined
179
+ /** Where a held server's output goes. */
180
+ outOf: (id: string) => HeldServer['out']
181
+ warn: (message: string) => void
182
+ }
183
+
184
+ /** The kept servers handed to the caller (`RunOptions.hold`) instead of waited on. */
185
+ export function holdServers(keepAlive: KeepAlive, args: HoldArgs): HeldPersistent {
186
+ const held = keepAlive.children
187
+ // A held server that dies on its own is said, as the foreground's
188
+ // keep-alive says it: `vx watch` sat on "watching" over a dead dev
189
+ // server. The holder's own stop() is not such a death.
190
+ let stopping = false
191
+ // Taken by a later run (`RunOptions.keep`): kept or stopped there.
192
+ const handed = new Set<Child>()
193
+ const servers = new Map<string, HeldServer>()
194
+ keepAlive.nodes.forEach((n, i) => {
195
+ const child = held[i]!
196
+ servers.set(n.id, {
197
+ child,
198
+ config: serverConfig(n, args.forwardArgs),
199
+ out: args.outOf(n.id),
200
+ handOver: () => void handed.add(child),
201
+ })
202
+ // One that died during the graph was said then.
203
+ if (hasEnded(child)) return
204
+ void child.exited.then((code) => {
205
+ if (!stopping && !handed.has(child) && code !== 0)
206
+ args.warn(`vx: ${n.id} exited with code ${code}`)
207
+ })
208
+ })
209
+ return {
210
+ ids: keepAlive.nodes.map((n) => n.id),
211
+ servers,
212
+ stop: (signal) => {
213
+ stopping = true
214
+ return terminateChildren(() => held.filter((c) => !handed.has(c)), signal)
215
+ },
216
+ }
217
+ }
218
+
219
+ /**
220
+ * A server that died on its own before the stop failed, whatever its
221
+ * outcome said when it became ready: the footer counted it a success over
222
+ * the run's red exit (item 1071). Its code is its own, or the shell's
223
+ * report of the signal that took it. In place: the renderer holds this
224
+ * outcome (events carry live refs) and closes the server's output block
225
+ * from it at runEnd, where a copy left it reading `running` under its exit
226
+ * line (WD-10).
227
+ */
228
+ export function failServer(
229
+ outcomes: ReadonlyMap<string, TaskOutcome>,
230
+ id: string,
231
+ code: number | string,
232
+ ): void {
233
+ const o = outcomes.get(id)
234
+ if (o === undefined) return
235
+ o.status = 'failed'
236
+ o.exitCode = typeof code === 'number' ? code : signalExitCode(code)
237
+ }
238
+
239
+ /**
240
+ * The end of the graph for its servers: which stay up (`selectKeepAlive`),
241
+ * the bounded shutdown of the rest, and each one that died on its own
242
+ * failed. A run that failed elsewhere keeps nothing: `vx run dev --all`
243
+ * with one server that never became ready sat on the others for good, so
244
+ * a script hung, and the Ctrl-C that ended it read 130 over the failure
245
+ * (C-60). A kept server's own crash ends the keep-alive wait as before,
246
+ * and `--continue=always` asked to keep going, and does. The watch loop
247
+ * (`hold`) still holds: a failing test in a cycle stopped the dev server
248
+ * the next change would restart anyway. A kept server that has already
249
+ * died fails too; the keep-alive wait names it.
250
+ */
251
+ export async function disposeServers(args: {
252
+ registry: ReadonlyMap<string, Child>
253
+ nodes: ReadonlyMap<string, TaskNode>
254
+ outcomes: ReadonlyMap<string, TaskOutcome>
255
+ /** Keep the requested servers up: the CLI foreground, or a hold. */
256
+ keep: boolean
257
+ hold: boolean
258
+ continueAlways: boolean
259
+ /** The servers that had ended when a stop landed; undefined without a stop. */
260
+ endedBeforeStop: ReadonlySet<Child> | undefined
261
+ }): Promise<{ keepAlive: KeepAlive; crashed: CrashedPersistent[] }> {
262
+ const { registry, outcomes, endedBeforeStop } = args
263
+ let keepAlive = selectKeepAlive(registry, args.nodes, args.keep)
264
+ const failedElsewhere =
265
+ ![...outcomes.values()].every((o) => isPassStatus(o.status)) ||
266
+ [...registry.values()].some(
267
+ (c) => hasEnded(c) && c.exitCode !== 0 && !keepAlive.children.includes(c),
268
+ )
269
+ if (failedElsewhere && !args.hold && !args.continueAlways) keepAlive = { nodes: [], children: [] }
270
+ const crashed = (await shutdownPersistent(registry, keepAlive.children)).filter(
271
+ (c) => endedBeforeStop?.has(registry.get(c.id)!) ?? true,
272
+ )
273
+ for (const c of crashed) failServer(outcomes, c.id, c.code)
274
+ keepAlive.nodes.forEach((n, i) => {
275
+ const child = keepAlive.children[i]!
276
+ if (!hasEnded(child) || child.exitCode === 0) return
277
+ if (endedBeforeStop !== undefined && !endedBeforeStop.has(child)) return
278
+ failServer(outcomes, n.id, child.exitCode ?? child.signalCode ?? 'unknown')
279
+ })
280
+ return { keepAlive, crashed }
281
+ }
@@ -11,7 +11,7 @@ import { machineParallelism, UserError } from '../util/index.js'
11
11
  import { isLocalExecutor, selectExecutor, type TaskExecutor } from '../exec/index.js'
12
12
  import { isGroupTask, type TaskNode } from '../graph/index.js'
13
13
  import { resolveDownloadModes } from './download-policy.js'
14
- import type { Logger } from './logger.js'
14
+ import { type Logger, warnOf } from '../report/index.js'
15
15
  import { executorLabel, resolveExecutors } from './plugin-host.js'
16
16
  import type { prepareRun } from './prepare.js'
17
17
 
@@ -218,11 +218,11 @@ export async function planExecutorOf(
218
218
  executors = await resolveExecutors(prepared.plugins, {
219
219
  workspaceRoot: prepared.workspaceRoot,
220
220
  cacheDir: prepared.cacheDir,
221
- warn: (m: string) => log.status(m),
221
+ warn: warnOf(log),
222
222
  concurrency: machineParallelism(),
223
223
  })
224
224
  } catch (err) {
225
- log.status(
225
+ warnOf(log)(
226
226
  `[vx] placement not shown — ${err instanceof Error ? err.message : String(err)} (the run would refuse on it)`,
227
227
  )
228
228
  return {}