@vzn/vx 0.0.638 → 0.0.640

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 (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -1,189 +1,169 @@
1
- // Markdown run report (`vx run --report=markdown` / `--report-file=<path>`).
2
- // A table — one row per task plus a header line of totals —
3
- // rendered after a run so CI can put it in a step summary
4
- // (`vx run ci --report-file="$GITHUB_STEP_SUMMARY"`). Pure: the run's
5
- // finished outcomes in, a markdown string out. No ANSI, no live region —
6
- // keep it machine-clean and diffable.
7
- //
8
- // Machine-clean is a property of this STRING, not of stdout: the status
9
- // logger shares that stream, so redirecting it captures the whole run log
10
- // too. `--report-file` is the sink that carries the property end to end.
1
+ // The report every plugin's teardown reads, and the run's own bytes kept
2
+ // in a remote that takes its writes (owner 2026-10-15): a failed task's
3
+ // log and the files its outputs left (`cache.upload.failures`), and the
4
+ // log of a task that saved no entry. Each task's bytes leave when it ends,
5
+ // off its path, like its entry's upload (owner 2026-10-16); the run only
6
+ // waits for them where it waits for every upload.
11
7
 
12
- import { failedLabel, skippedLabel, type OutcomeView } from './events.js'
13
- import { neverStarted } from './summary.js'
8
+ import {
9
+ packRunOutputs,
10
+ resolveOutputs,
11
+ resolveWorkspaceOutputs,
12
+ runEntryName,
13
+ } from '../cache/index.js'
14
+ import type { CiContext } from './run-context.js'
15
+ import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
16
+ import { relPosix } from '../util/index.js'
17
+ import { decodeOutputLog } from './output-log.js'
18
+ import type { RunReport, RunReportTask } from './plugin.js'
14
19
 
15
- /**
16
- * One finished run, reduced to what a report needs. Used to live in
17
- * `protocol.ts` as the return type of the whole-run `backend` seam; that
18
- * seam is gone (a run always executes in-process — see
19
- * `docs/modules/executor.md`), so the shape lives with its only consumer.
20
- */
21
- export interface RunResult {
22
- ok: boolean
23
- outcomes: OutcomeView[]
20
+ /** Stores one run entry; resolves whether a remote kept it. */
21
+ export type KeepRunEntry = (name: string, bytes: Uint8Array) => Promise<boolean>
22
+
23
+ export interface RunKeeperArgs {
24
+ readonly runId: string
25
+ readonly workspaceRoot: string
26
+ readonly nestedDirsByProject: ReadonlyMap<string, string[]>
27
+ readonly keep: KeepRunEntry
28
+ /** Whether a failed task's log and outputs are kept (`cache.upload.failures`). */
29
+ readonly failures: boolean
24
30
  }
25
- import { tallyViews, type Tally } from './tally.js'
26
31
 
27
- /**
28
- * The report header's numbers. The outcome BUCKETS come from the shared
29
- * `tallyViews` — the same rules the terminal summary and `--summarize` use,
30
- * so three surfaces describing one run cannot disagree. Only the two
31
- * durations are report-specific.
32
- *
33
- * This used to be a private copy of the partition, and it drifted: it had no
34
- * group filter (it could not have one — `OutcomeView` had no `isGroup`), so
35
- * every organizational node was counted as a successful task and rendered as
36
- * a row claiming `success | miss | 0ms`.
37
- */
38
- interface ReportTally extends Tally {
39
- /** Wall-clock ms summed over tasks that ran a command, an aborted one's time so far included. */
40
- executedMs: number
41
- /** The aborted tasks the stop reached before they ran, as the terminal splits them. */
42
- notRun: number
43
- /**
44
- * Ms of work the cache SKIPPED — the sum of the hits' STORED exec times.
45
- * Deliberately not `durationMs`, which for a hit is the restore this run
46
- * paid: summing that reported a 2.01 s task as "6ms saved".
47
- */
48
- savedMs: number
32
+ /** Whether a run on CI or off it keeps failures, by `cache.upload.failures` (default `'ci'`). */
33
+ export function keepsFailures(where: unknown, ci: boolean): boolean {
34
+ const at = where ?? 'ci'
35
+ return at === 'always' || (at === 'ci') === ci
49
36
  }
50
37
 
51
- function tally(outcomes: readonly OutcomeView[]): ReportTally {
52
- const t: ReportTally = { ...tallyViews(outcomes), executedMs: 0, savedMs: 0, notRun: 0 }
53
- for (const o of outcomes) {
54
- if (o.isGroup === true) continue
55
- switch (o.status) {
56
- case 'success':
57
- case 'failed':
58
- t.executedMs += o.durationMs
59
- break
60
- case 'aborted':
61
- if (neverStarted(o)) t.notRun++
62
- else t.executedMs += o.durationMs
63
- break
64
- case 'cache-hit':
65
- case 'cache-hit-remote':
66
- // An outcome that doesn't know what it skipped contributes nothing:
67
- // better to omit the claim than to substantiate it with the restore
68
- // cost. (Only reachable across a version skew — every hit this
69
- // binary produces carries the stored duration.)
70
- t.savedMs += o.storedDurationMs ?? 0
71
- break
72
- }
73
- }
74
- return t
38
+ /** Concurrent run-entry writes; more queue. */
39
+ const KEEP_CONCURRENCY = 8
40
+
41
+ /** The files `node`'s declared outputs match on disk now, workspace-relative and sorted. */
42
+ async function outputsLeft(
43
+ node: TaskNode,
44
+ workspaceRoot: string,
45
+ nestedProjectDirs: string[],
46
+ ): Promise<string[]> {
47
+ const declared = node.config.cache?.outputs
48
+ if (declared === undefined) return []
49
+ const [own, ws] = await Promise.all([
50
+ resolveOutputs({ projectDir: node.projectDir, outputs: declared.files, nestedProjectDirs }),
51
+ resolveWorkspaceOutputs({ workspaceRoot, outputs: declared.workspaceFiles ?? [] }),
52
+ ])
53
+ return [...new Set([...own, ...ws].map((p) => relPosix(workspaceRoot, p)))].sort()
75
54
  }
76
55
 
77
- function statusWord(o: OutcomeView): string {
78
- switch (o.status) {
79
- case 'success':
80
- return 'success'
81
- case 'cache-hit':
82
- case 'cache-hit-remote':
83
- return 'success'
84
- case 'failed':
85
- return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady, o.inputMoved)
86
- case 'skipped':
87
- return skippedLabel(o.blockedBy)
88
- case 'aborted':
89
- return neverStarted(o) ? 'not run' : 'aborted'
90
- default:
91
- return o.status
56
+ /** Keeps each task's run bytes as it ends, and names them in the run's report. */
57
+ export class RunKeeper {
58
+ private readonly kept = new Map<string, { log?: string; outputs?: string }>()
59
+ private readonly queue: (() => Promise<void>)[] = []
60
+ private readonly pending = new Set<Promise<void>>()
61
+ private active = 0
62
+ private stopped = false
63
+
64
+ constructor(private readonly args: RunKeeperArgs) {}
65
+
66
+ /** A task ended: queue what of it the run keeps. */
67
+ taskDone(o: TaskOutcome): void {
68
+ if (this.stopped || isGroupTask(o.node)) return
69
+ const { runId, workspaceRoot } = this.args
70
+ const id = o.node.id
71
+ const slot = (): { log?: string; outputs?: string } => {
72
+ let s = this.kept.get(id)
73
+ if (s === undefined) this.kept.set(id, (s = {}))
74
+ return s
75
+ }
76
+ const runLog = o.runLog
77
+ if (runLog !== undefined) {
78
+ const text = decodeOutputLog(runLog)
79
+ .map((c) => c.text)
80
+ .join('')
81
+ if (text.length > 0) {
82
+ const name = runEntryName(runId, id, 'log')
83
+ const bytes = new TextEncoder().encode(text)
84
+ this.enqueue(async () => {
85
+ if (await this.args.keep(name, bytes)) slot().log = name
86
+ })
87
+ }
88
+ }
89
+ if (this.args.failures && o.status === 'failed' && o.where === undefined) {
90
+ // Read now: a later task may write where this one's outputs lie.
91
+ const packed = outputsLeft(
92
+ o.node,
93
+ workspaceRoot,
94
+ this.args.nestedDirsByProject.get(o.node.projectName) ?? [],
95
+ ).then((paths) => packRunOutputs(workspaceRoot, paths))
96
+ const name = runEntryName(runId, id, 'outputs')
97
+ this.enqueue(async () => {
98
+ const bytes = await packed
99
+ if (bytes !== null && (await this.args.keep(name, bytes))) slot().outputs = name
100
+ })
101
+ }
92
102
  }
93
- }
94
103
 
95
- function cacheWord(o: OutcomeView): string {
96
- switch (o.status) {
97
- case 'cache-hit':
98
- return o.restored === false ? 'up-to-date' : 'local'
99
- case 'cache-hit-remote':
100
- return o.restored === false ? 'up-to-date' : 'remote'
101
- case 'success':
102
- case 'failed':
103
- return o.noCache === true ? 'no-cache' : 'miss'
104
- default:
105
- return '—'
104
+ /** Resolves once every queued write has settled, or the run was stopped. */
105
+ async settled(): Promise<void> {
106
+ while (!this.stopped && this.pending.size > 0) await Promise.all(this.pending)
106
107
  }
107
- }
108
108
 
109
- function fmtDuration(ms: number): string {
110
- if (ms < 1000) return `${Math.round(ms)}ms`
111
- return `${(ms / 1000).toFixed(2)}s`
112
- }
109
+ /** A stopped run starts no more writes and stops waiting on those in flight. */
110
+ stop(): void {
111
+ this.stopped = true
112
+ this.queue.length = 0
113
+ }
113
114
 
114
- /**
115
- * Make a value safe inside a GFM table cell. Task names are arbitrary TS
116
- * object keys and the loader accepts `|` and newlines, either of which
117
- * silently breaks the table on the consumer (`>> $GITHUB_STEP_SUMMARY`):
118
- * a bare pipe adds a column, a newline splits the row.
119
- *
120
- * Exported because this file is not the only markdown table describing a run:
121
- * the cloud plugin's GitHub job summary renders the same data from the same
122
- * unvalidated names and shipped WITHOUT this escape, so a `|` in a task name
123
- * or an output path shifted its columns. One definition, so the two
124
- * cannot disagree about what a cell may contain.
125
- *
126
- * A pipe splits the row unless an ODD run of backslashes precedes it (GFM
127
- * reads `\\` as one escaped backslash), so a pipe after an odd run is already
128
- * escaped and one more backslash would free it: `a\|b` became `a\\|b`, two
129
- * cells. A lone `\r` ends a line too.
130
- */
131
- export function escapeMarkdownCell(value: string): string {
132
- return value
133
- .replace(/(\\*)\|/g, (pipe, run: string) => (run.length % 2 === 0 ? `${run}\\|` : pipe))
134
- .replace(/\r\n|\r|\n/g, ' ')
135
- }
115
+ /** What was kept of each task, by id, for `reportOf`. */
116
+ keptOf(id: string): { log?: string; outputs?: string } | undefined {
117
+ return this.kept.get(id)
118
+ }
136
119
 
137
- const cell = escapeMarkdownCell
120
+ private enqueue(job: () => Promise<void>): void {
121
+ let done!: () => void
122
+ const p = new Promise<void>((resolve) => (done = resolve))
123
+ this.pending.add(p)
124
+ this.queue.push(async () => {
125
+ try {
126
+ if (!this.stopped) await job()
127
+ } catch {
128
+ // a record, never the run's verdict
129
+ } finally {
130
+ this.pending.delete(p)
131
+ done()
132
+ }
133
+ })
134
+ this.pump()
135
+ }
138
136
 
139
- /**
140
- * Render the finished run as a markdown report. A header line of totals
141
- * followed by a per-task table. Self-contained — everything is derived
142
- * from the result's outcomes.
143
- */
144
- export function formatRunReportMarkdown(result: RunResult): string {
145
- const t = tally(result.outcomes)
146
- const cached = t.cachedLocal + t.cachedRemote
147
- // The terminal summary's words and counts: every task in the run counts
148
- // (M, 2026-10-14), and a task the stop reached before it ran was never
149
- // aborted.
150
- const aborted = t.aborted - t.notRun
151
- const count = t.total + aborted + t.notRun
152
- const parts = [
153
- `**${count} task${count === 1 ? '' : 's'}**`,
154
- `${t.successful} success`,
155
- `${t.failed} failed`,
156
- // What the hits did to the disk, as the run's summary splits them.
157
- cached > 0
158
- ? `${cached} cached (${t.upToDate} up-to-date, ${t.restoredLocal + t.restoredRemote} restored)`
159
- : '0 cached',
160
- ]
161
- if (t.skipped > 0) parts.push(`${t.skipped} skipped`)
162
- if (aborted > 0) parts.push(`${aborted} aborted`)
163
- if (t.notRun > 0) parts.push(`${t.notRun} not run`)
164
- parts.push(`${fmtDuration(t.executedMs)} total`)
165
- if (t.savedMs > 0) parts.push(`${fmtDuration(t.savedMs)} saved`)
137
+ private pump(): void {
138
+ while (this.active < KEEP_CONCURRENCY && this.queue.length > 0) {
139
+ const job = this.queue.shift()!
140
+ this.active++
141
+ void job().finally(() => {
142
+ this.active--
143
+ this.pump()
144
+ })
145
+ }
146
+ }
147
+ }
166
148
 
167
- // Only a stop (a signal, `RunOptions.signal`) makes an `aborted` outcome,
168
- // so one is how an interrupted run tells itself from a failed one.
169
- const heading = t.aborted > 0 ? 'interrupted' : result.ok ? 'passed' : 'failed'
170
- const lines: string[] = []
171
- lines.push(`## vx run — ${heading}`)
172
- lines.push('')
173
- lines.push(parts.join(' · '))
174
- lines.push('')
175
- lines.push('| Task | Status | Cache | Duration |')
176
- lines.push('| --- | --- | --- | --- |')
177
- for (const o of result.outcomes) {
178
- // A group node did no work: no command, no cache decision, 0ms. Rendering
179
- // one as `success | miss | 0ms` invents a task the user never wrote a
180
- // command for. Same exclusion the header's tally makes.
181
- if (o.isGroup === true) continue
182
- lines.push(
183
- `| ${cell(o.taskId)} | ${cell(statusWord(o))} | ${cell(cacheWord(o))} | ${cell(
184
- neverStarted(o) ? '—' : fmtDuration(o.durationMs),
185
- )} |`,
186
- )
149
+ /** The report: every task, with the names of what `keeper` kept of it. */
150
+ export function reportOf(
151
+ final: readonly TaskOutcome[],
152
+ run: { runId: string; commit: string | null; ciRun: CiContext; ok: boolean },
153
+ keeper: RunKeeper | undefined,
154
+ ): RunReport {
155
+ const tasks: RunReportTask[] = []
156
+ for (const o of final) {
157
+ if (isGroupTask(o.node)) continue
158
+ const kept = keeper?.keptOf(o.node.id)
159
+ tasks.push({
160
+ id: o.node.id,
161
+ status: o.status,
162
+ durationMs: Math.round(o.durationMs),
163
+ ...(o.hash !== undefined ? { key: o.hash } : {}),
164
+ ...(kept?.log !== undefined ? { log: kept.log } : {}),
165
+ ...(kept?.outputs !== undefined ? { outputs: kept.outputs } : {}),
166
+ })
187
167
  }
188
- return lines.join('\n') + '\n'
168
+ return { runId: run.runId, commit: run.commit, ciRun: run.ciRun, ok: run.ok, tasks }
189
169
  }
@@ -5,7 +5,6 @@
5
5
 
6
6
  import type { WorkspaceConfig } from '../config.js'
7
7
  import { resolveSetting } from '../settings.js'
8
- import { setSettleBounds, type SettleBound } from '../util/index.js'
9
8
  import type { RunOptions } from './options.js'
10
9
 
11
10
  const FROM_OPTIONS = {
@@ -14,15 +13,12 @@ const FROM_OPTIONS = {
14
13
  timeout: 'run.timeout',
15
14
  retries: 'run.retries',
16
15
  continueMode: 'run.continue',
17
- readyNotice: 'run.readyNotice',
18
16
  download: 'cache.download',
19
17
  } as const satisfies Partial<Record<keyof RunOptions, string>>
20
18
 
21
19
  /**
22
20
  * `options` with each run-level setting it leaves unset filled from the
23
- * environment, then the workspace. Arms the settle bounds (`util/settle`,
24
- * below settings) with the flag's or workspace's `run.killGrace` and
25
- * `run.teardownTimeout`: the variable sits between them, read per call.
21
+ * environment, then the workspace.
26
22
  */
27
23
  export function withRunSettings(
28
24
  options: RunOptions,
@@ -33,16 +29,5 @@ export function withRunSettings(
33
29
  const { value } = resolveSetting(path, workspace, options[field as keyof RunOptions])
34
30
  if (value !== undefined) out[field] = value
35
31
  }
36
- const bound = (flag: number | undefined, path: string): SettleBound | undefined => {
37
- if (flag !== undefined) return { ms: flag, from: 'flag' }
38
- const { value } = resolveSetting(path, workspace, undefined, {})
39
- return value === undefined ? undefined : { ms: value as number, from: 'workspace' }
40
- }
41
- const killGrace = bound(options.killGrace, 'run.killGrace')
42
- const teardown = bound(options.teardownTimeout, 'run.teardownTimeout')
43
- setSettleBounds({
44
- ...(killGrace !== undefined ? { killGrace } : {}),
45
- ...(teardown !== undefined ? { teardown } : {}),
46
- })
47
32
  return out as unknown as RunOptions
48
33
  }
@@ -0,0 +1,57 @@
1
+ // The steps a run did not wait on (`vx:prefetch`), for `vx last` (M,
2
+ // 2026-10-16): they never show in a run's output. One file per run that
3
+ // had one, `<cacheDir>/steps/<runId>.json`, the newest 50 kept, as
4
+ // run-failures.ts keeps failed output: files, not cache.db, so the index
5
+ // layout stays as it was. A run with none writes nothing.
6
+
7
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
8
+ import path from 'node:path'
9
+ import { isFsRefusal } from '../util/index.js'
10
+
11
+ /** Runs whose background steps are kept; run ids are UUIDv7, so names sort by age. */
12
+ const KEPT = 50
13
+
14
+ /** One of vx's own steps that ran beside the tasks. */
15
+ export interface RunStep {
16
+ id: string
17
+ durationMs: number
18
+ ok: boolean
19
+ }
20
+
21
+ const dirOf = (cacheDir: string): string => path.join(cacheDir, 'steps')
22
+
23
+ /** Keep `steps` for `runId`; a refused write costs only them. */
24
+ export function writeRunSteps(
25
+ cacheDir: string,
26
+ runId: string,
27
+ steps: readonly RunStep[],
28
+ warn: (message: string) => void,
29
+ ): void {
30
+ if (steps.length === 0) return
31
+ try {
32
+ const dir = dirOf(cacheDir)
33
+ mkdirSync(dir, { recursive: true })
34
+ const file = path.join(dir, `${runId}.json`)
35
+ const tmp = `${file}.${process.pid}.tmp`
36
+ writeFileSync(tmp, JSON.stringify(steps))
37
+ renameSync(tmp, file)
38
+ const names = readdirSync(dir)
39
+ .filter((n) => n.endsWith('.json'))
40
+ .sort()
41
+ for (const n of names.slice(0, -KEPT)) rmSync(path.join(dir, n), { force: true })
42
+ } catch (err) {
43
+ if (!isFsRefusal(err)) throw err
44
+ warn(`[vx] background steps not kept: ${err.message} — the verdict above stands`)
45
+ }
46
+ }
47
+
48
+ /** The background steps `runId` kept; none when it had none or they are gone. */
49
+ export function runSteps(cacheDir: string, runId: string): RunStep[] {
50
+ try {
51
+ return JSON.parse(
52
+ readFileSync(path.join(dirOf(cacheDir), `${runId}.json`), 'utf8'),
53
+ ) as RunStep[]
54
+ } catch {
55
+ return []
56
+ }
57
+ }
@@ -0,0 +1,153 @@
1
+ // One stop for the run: an embedder's `RunOptions.signal` and a process
2
+ // signal both abort it. The scheduler stops dispatching (it reads the
3
+ // signal), the children are torn down, and run() returns through its own
4
+ // end-of-run path. Owns the run-scoped registries of live subprocesses:
5
+ // - `liveChildren`: in-flight children. The runner adds/removes each
6
+ // child around its spawn (persistent children stay until they exit).
7
+ // - `persistentRegistry`: ready persistent tasks (dev servers,
8
+ // watchers). executeTask spawns them but does NOT await their exit;
9
+ // ownership moves here so the orchestrator can SIGTERM them once the
10
+ // rest of the graph finishes.
11
+ // A SIGINT/SIGTERM mid-run forwards that signal to everything in both
12
+ // (`signals.ts`); `detach` removes the handlers so repeated run() calls
13
+ // (test suites) never stack listeners.
14
+
15
+ import { killGraceMs, teardownTimeoutMs } from '../util/index.js'
16
+ import { type Logger, warnOf } from '../report/index.js'
17
+ import { hasEnded } from './persistent.js'
18
+ import {
19
+ forwardedSignal,
20
+ forwardSignals,
21
+ SIGNAL_SHUTDOWN_GRACE_MS,
22
+ type StopSignal,
23
+ terminateChildren,
24
+ } from './signals.js'
25
+
26
+ type Child = ReturnType<typeof Bun.spawn>
27
+
28
+ export interface RunStop {
29
+ readonly signal: AbortSignal
30
+ readonly liveChildren: Set<Child>
31
+ readonly persistentRegistry: Map<string, Child>
32
+ /**
33
+ * The process signal that stopped the run. A run a process signal
34
+ * stopped records no history: until item 849 the handler exited before
35
+ * any outcome landed, and a Ctrl-C'd run then read in `vx last` as
36
+ * FAILED with 0 tasks (item 854). An embedder's abort is its own call
37
+ * and keeps its record.
38
+ */
39
+ readonly stoppedBy: StopSignal | undefined
40
+ /**
41
+ * The stop's teardown of every child, awaited before run() leaves: a
42
+ * task's shell can die of the signal at once while a child it
43
+ * backgrounded ignores it and dies only to the SIGKILL after the grace,
44
+ * and a run that returned first left that child running past vx's exit
45
+ * (turborepo#14043's class).
46
+ */
47
+ readonly aborting: Promise<void> | undefined
48
+ /**
49
+ * The servers that had ended when the stop landed: those crashed. One
50
+ * that ends after it is the stop's own kill, and was named a crash
51
+ * (item 1061).
52
+ */
53
+ readonly endedBeforeStop: ReadonlySet<Child> | undefined
54
+ /** The graph finished: a server ending from here on is said by the end of the run. */
55
+ graphDone(): void
56
+ /** Removes the signal handlers and abort listeners. */
57
+ detach(): void
58
+ /** run() has left its `finally`: a process signal waits for this before it exits. */
59
+ left(): void
60
+ }
61
+
62
+ export function runStop(args: {
63
+ log: Logger
64
+ cache: { close(): void }
65
+ /** `RunOptions.signal`. */
66
+ external: AbortSignal | undefined
67
+ /** `RunOptions.handleSignals`. */
68
+ handleSignals: boolean
69
+ handsTerminal: boolean
70
+ /** How many plugins have a teardown: each gets its own bound before a signal exits. */
71
+ teardowns: number
72
+ /** Called first when the run is stopped. */
73
+ onStop: () => void
74
+ }): RunStop {
75
+ const { log, external } = args
76
+ const liveChildren = new Set<Child>()
77
+ // A server that ends on its own while the graph still runs is said at
78
+ // once: its dependants were failing against it, and the end of the run
79
+ // was the first word of it (C-69). Not once the graph is done (the end
80
+ // of the run and the keep-alive wait say it) nor under a stop.
81
+ let graphDone = false
82
+ const persistentRegistry = new (class extends Map<string, Child> {
83
+ override set(id: string, child: Child): this {
84
+ super.set(id, child)
85
+ void child.exited.then((code) => {
86
+ if (code !== 0 && !graphDone && !controller.signal.aborted)
87
+ warnOf(log)(`vx: ${id} exited with code ${code} while the run went on`)
88
+ })
89
+ return this
90
+ }
91
+ })()
92
+ const controller = new AbortController()
93
+ const forwardAbort = (): void => controller.abort(external?.reason)
94
+ if (external?.aborted === true) forwardAbort()
95
+ else external?.addEventListener('abort', forwardAbort, { once: true })
96
+ let stoppedBy: StopSignal | undefined
97
+ let left = (): void => {}
98
+ const runLeft = new Promise<void>((resolve) => {
99
+ left = resolve
100
+ })
101
+ const signals = forwardSignals({
102
+ enabled: args.handleSignals,
103
+ log,
104
+ cache: args.cache,
105
+ liveChildren,
106
+ persistentRegistry,
107
+ stop: (signal) => {
108
+ stoppedBy = signal
109
+ controller.abort(signal)
110
+ },
111
+ done: runLeft,
112
+ handsTerminal: args.handsTerminal,
113
+ // The grace, then one flush and each teardown at their own bound, and
114
+ // slack for the summary and the cache close.
115
+ boundMs:
116
+ killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS) + teardownTimeoutMs() * (1 + args.teardowns) + 2_000,
117
+ })
118
+ let aborting: Promise<void> | undefined
119
+ let endedBeforeStop: ReadonlySet<Child> | undefined
120
+ const onAbort = (): void => {
121
+ args.onStop()
122
+ endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
123
+ aborting = terminateChildren(
124
+ () => [...liveChildren, ...persistentRegistry.values()],
125
+ forwardedSignal(controller.signal.reason),
126
+ )
127
+ }
128
+ if (controller.signal.aborted) onAbort()
129
+ else controller.signal.addEventListener('abort', onAbort, { once: true })
130
+ return {
131
+ signal: controller.signal,
132
+ liveChildren,
133
+ persistentRegistry,
134
+ get stoppedBy() {
135
+ return stoppedBy
136
+ },
137
+ get aborting() {
138
+ return aborting
139
+ },
140
+ get endedBeforeStop() {
141
+ return endedBeforeStop
142
+ },
143
+ graphDone: () => {
144
+ graphDone = true
145
+ },
146
+ detach: () => {
147
+ signals.remove()
148
+ external?.removeEventListener('abort', forwardAbort)
149
+ controller.signal.removeEventListener('abort', onAbort)
150
+ },
151
+ left: () => left(),
152
+ }
153
+ }