@vzn/vx 0.0.637 → 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 (126) hide show
  1. package/docs/caching.md +84 -28
  2. package/docs/cli.md +273 -408
  3. package/docs/execution.md +19 -44
  4. package/docs/features.md +13 -15
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +68 -61
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/schemas/plan.json +2 -2
  10. package/skills/vx/SKILL.md +19 -6
  11. package/src/bin.ts +13 -7
  12. package/src/cache/cache.ts +60 -11
  13. package/src/cache/git-inputs.ts +6 -0
  14. package/src/cache/index.ts +10 -2
  15. package/src/cache/inputs.ts +0 -24
  16. package/src/cache/layer.ts +32 -12
  17. package/src/cache/layered-cache.ts +112 -315
  18. package/src/cache/plugin-cache.ts +240 -0
  19. package/src/cache/policy.ts +9 -0
  20. package/src/cache/remote-lane.ts +383 -0
  21. package/src/cache/run-history.ts +92 -8
  22. package/src/cache/run-store.ts +77 -0
  23. package/src/cache/schema.ts +44 -13
  24. package/src/cache/scoped-cache.ts +22 -2
  25. package/src/cli/cache.ts +33 -16
  26. package/src/cli/completions.ts +11 -7
  27. package/src/cli/foreign-flags.ts +10 -22
  28. package/src/cli/help.ts +22 -38
  29. package/src/cli/index.ts +25 -11
  30. package/src/cli/info.ts +8 -6
  31. package/src/cli/init.ts +14 -15
  32. package/src/cli/last.ts +20 -10
  33. package/src/cli/lock.ts +39 -27
  34. package/src/cli/plan-format.ts +10 -61
  35. package/src/cli/plugin-commands.ts +77 -6
  36. package/src/cli/plugin-templates.ts +1 -1
  37. package/src/cli/run.ts +31 -191
  38. package/src/cli/select.ts +18 -9
  39. package/src/cli/show.ts +72 -40
  40. package/src/cli/task-verb.ts +4 -4
  41. package/src/cli/upgrade.ts +132 -39
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +7 -2
  44. package/src/cli/watch-judge.ts +6 -1
  45. package/src/cli/watch.ts +73 -34
  46. package/src/cli/why.ts +14 -5
  47. package/src/cli/workspace-config.ts +2 -2
  48. package/src/config.ts +0 -6
  49. package/src/exec/executor.ts +1 -1
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/index.ts +9 -1
  53. package/src/graph/priorities.ts +1 -1
  54. package/src/graph/scheduler.ts +12 -16
  55. package/src/graph/task-graph.ts +119 -21
  56. package/src/graph/task-status.ts +95 -0
  57. package/src/{orchestrator → history}/failure-mode.ts +20 -11
  58. package/src/{orchestrator → history}/history.ts +61 -5
  59. package/src/history/index.ts +48 -0
  60. package/src/{orchestrator → history}/metrics.ts +14 -23
  61. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  62. package/src/index.ts +21 -29
  63. package/src/orchestrator/affected-tasks.ts +8 -3
  64. package/src/orchestrator/doctor.ts +2 -2
  65. package/src/orchestrator/execute-args.ts +80 -0
  66. package/src/orchestrator/execute-task.ts +47 -429
  67. package/src/orchestrator/fingerprint-watch.ts +2 -2
  68. package/src/orchestrator/index.ts +3 -74
  69. package/src/orchestrator/miss-save.ts +33 -3
  70. package/src/orchestrator/options.ts +1 -36
  71. package/src/orchestrator/output-host.ts +4 -3
  72. package/src/orchestrator/persistent-task.ts +321 -0
  73. package/src/orchestrator/persistent.ts +130 -3
  74. package/src/orchestrator/placement.ts +3 -3
  75. package/src/orchestrator/plan.ts +18 -5
  76. package/src/orchestrator/plugin-host.ts +77 -47
  77. package/src/orchestrator/plugin.ts +86 -14
  78. package/src/orchestrator/prepare.ts +74 -31
  79. package/src/orchestrator/projects.ts +3 -0
  80. package/src/orchestrator/remote-prefetch.ts +23 -6
  81. package/src/orchestrator/run-artifacts.ts +5 -177
  82. package/src/orchestrator/run-context.ts +55 -0
  83. package/src/orchestrator/run-failures.ts +15 -3
  84. package/src/orchestrator/run-records.ts +36 -4
  85. package/src/orchestrator/run-report.ts +81 -179
  86. package/src/orchestrator/run-settings.ts +1 -16
  87. package/src/orchestrator/run-stop.ts +153 -0
  88. package/src/orchestrator/run.ts +313 -555
  89. package/src/orchestrator/sandbox-fix.ts +0 -16
  90. package/src/orchestrator/signals.ts +14 -6
  91. package/src/orchestrator/stable-keys.ts +107 -3
  92. package/src/orchestrator/steps.ts +50 -0
  93. package/src/orchestrator/task-hash.ts +3 -16
  94. package/src/orchestrator/task-log-buffer.ts +1 -1
  95. package/src/orchestrator/task-process.ts +94 -0
  96. package/src/orchestrator/telemetry-host.ts +1 -1
  97. package/src/orchestrator/telemetry.ts +6 -83
  98. package/src/{orchestrator → report}/events.ts +41 -8
  99. package/src/{orchestrator → report}/forecast.ts +35 -2
  100. package/src/{orchestrator → report}/framed-output.ts +39 -34
  101. package/src/report/index.ts +36 -0
  102. package/src/{orchestrator → report}/logger.ts +166 -21
  103. package/src/{orchestrator → report}/status-line.ts +84 -7
  104. package/src/{orchestrator → report}/summary.ts +179 -63
  105. package/src/report/tally.ts +83 -0
  106. package/src/settings.ts +13 -23
  107. package/src/{orchestrator → util}/colors.ts +49 -4
  108. package/src/util/config-frame.ts +31 -2
  109. package/src/util/errors.ts +32 -0
  110. package/src/util/git-ignored.ts +143 -0
  111. package/src/util/index.ts +25 -3
  112. package/src/util/refusal-notice.ts +111 -0
  113. package/src/util/report.ts +39 -0
  114. package/src/util/settle.ts +14 -39
  115. package/src/util/tail.ts +1 -1
  116. package/src/util/ui.ts +197 -0
  117. package/src/workspace/affected.ts +20 -23
  118. package/src/workspace/dependency-rules.ts +12 -4
  119. package/src/workspace/migration.ts +87 -91
  120. package/src/workspace/workspace.ts +5 -4
  121. package/schemas/summary.json +0 -133
  122. package/src/cache/chained-cache.ts +0 -276
  123. package/src/orchestrator/tally.ts +0 -112
  124. /package/src/{orchestrator → report}/path-links.ts +0 -0
  125. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  126. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -10,7 +10,8 @@ import type { InvocationRecord, RunRecord } from '../cache/index.js'
10
10
  import { isGroupTask, type TaskOutcome } from '../graph/index.js'
11
11
  import { VERSION } from '../version.js'
12
12
  import type { CiContext, GitContext, HostContext } from './run-context.js'
13
- import { isCacheHit, taskTelemetryOf, type TaskTelemetry } from './telemetry.js'
13
+ import { taskTelemetryOf, type TaskTelemetry } from './telemetry.js'
14
+ import { isCacheHit } from '../graph/index.js'
14
15
 
15
16
  export interface RunRecordsInput {
16
17
  outcomes: readonly TaskOutcome[]
@@ -27,17 +28,18 @@ export interface RunRecordsInput {
27
28
  ok: boolean
28
29
  command: string
29
30
  requestedTasks: readonly string[]
30
- /** Already compacted (`compactCachePolicy`), the form the header row stores. */
31
+ /** Already formatted (`formatCachePolicy`), the form the header row stores. */
31
32
  cachePolicy: string
32
33
  concurrency: number
33
34
  flow: InvocationRecord['flow']
34
35
  forwardArgs?: readonly string[] | undefined
35
- tags: Record<string, string>
36
36
  git: GitContext
37
37
  ci: CiContext
38
38
  host: HostContext
39
39
  /** Build the telemetry mirror; a run with no sink allocates none. */
40
40
  withTelemetry: boolean
41
+ /** The run's remote failures by key, so far (`Cache.traffic`). */
42
+ remoteTraffic?: ReadonlyMap<string, { failures: readonly string[] }>
41
43
  }
42
44
 
43
45
  export interface RunRecords {
@@ -46,6 +48,15 @@ export interface RunRecords {
46
48
  telemetryTasks: TaskTelemetry[]
47
49
  }
48
50
 
51
+ /** A key's remote failures as its `runs` row stores them; none when it met none. */
52
+ function remoteErrorOf(
53
+ traffic: ReadonlyMap<string, { failures: readonly string[] }> | undefined,
54
+ hash: string | undefined,
55
+ ): string | undefined {
56
+ const failures = hash === undefined ? undefined : traffic?.get(hash)?.failures
57
+ return failures === undefined || failures.length === 0 ? undefined : failures.join('; ')
58
+ }
59
+
49
60
  /**
50
61
  * Every non-group, non-aborted outcome gets a row. An outcome with NO hash
51
62
  * (a `skipped` task never probed the cache; a `persistent` one is never
@@ -71,6 +82,7 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
71
82
  if (o.status === 'aborted') continue
72
83
  if (input.withTelemetry) telemetryTasks.push(taskTelemetryOf(o))
73
84
  const blockedBy = o.blockedBy ?? input.tainted?.get(o.node.id)
85
+ const remoteError = remoteErrorOf(input.remoteTraffic, o.hash)
74
86
  runs.push({
75
87
  ...(o.hash !== undefined ? { hash: o.hash } : {}),
76
88
  project: o.node.projectName,
@@ -113,6 +125,7 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
113
125
  ...(o.timedOut === true ? { timedOut: true as const } : {}),
114
126
  ...(o.sandboxViolations !== undefined ? { sandboxViolations: o.sandboxViolations } : {}),
115
127
  ...(o.notReady !== undefined ? { notReady: o.notReady } : {}),
128
+ ...(remoteError !== undefined ? { remoteError } : {}),
116
129
  })
117
130
  if (o.status === 'failed') failedCount++
118
131
  if (o.status === 'cache-hit') hitLocalCount++
@@ -151,7 +164,26 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
151
164
  os: input.host.os,
152
165
  arch: input.host.arch,
153
166
  vxVersion: VERSION,
154
- tags: JSON.stringify(input.tags),
155
167
  }
156
168
  return { runs, invocation, telemetryTasks }
157
169
  }
170
+
171
+ /**
172
+ * The remote failures uploads met after the run's rows went in (the uploads
173
+ * drain later): each row whose text is now short of its key's is rewritten.
174
+ */
175
+ export function recordLateRemoteErrors(
176
+ cache: {
177
+ readonly traffic: { readonly keys: ReadonlyMap<string, { failures: readonly string[] }> }
178
+ recordRemoteErrors(runId: string, errors: readonly (readonly [string, string])[]): void
179
+ },
180
+ runs: readonly RunRecord[],
181
+ runId: string,
182
+ ): void {
183
+ const late: [string, string][] = []
184
+ for (const r of runs) {
185
+ const now = remoteErrorOf(cache.traffic.keys, r.hash)
186
+ if (now !== undefined && now !== r.remoteError) late.push([r.hash!, now])
187
+ }
188
+ if (late.length > 0) cache.recordRemoteErrors(runId, late)
189
+ }
@@ -1,189 +1,91 @@
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, an uncached task's log.
11
4
 
12
- import { failedLabel, skippedLabel, type OutcomeView } from './events.js'
13
- import { neverStarted } from './summary.js'
5
+ import {
6
+ packRunOutputs,
7
+ resolveOutputs,
8
+ resolveWorkspaceOutputs,
9
+ runEntryName,
10
+ } from '../cache/index.js'
11
+ import type { CiContext } from './run-context.js'
12
+ import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
13
+ import { relPosix } from '../util/index.js'
14
+ import { decodeOutputLog } from './output-log.js'
15
+ import type { RunReport, RunReportTask } from './plugin.js'
14
16
 
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[]
17
+ export interface RunReportArgs {
18
+ readonly runId: string
19
+ readonly commit: string | null
20
+ readonly ciRun: CiContext
21
+ readonly ok: boolean
22
+ readonly workspaceRoot: string
23
+ readonly nestedDirsByProject: ReadonlyMap<string, string[]>
24
+ /** Stores one run entry; undefined when no remote takes the run's writes. */
25
+ readonly keep: ((name: string, bytes: Uint8Array) => Promise<boolean>) | undefined
24
26
  }
25
- import { tallyViews, type Tally } from './tally.js'
26
27
 
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
28
+ /** The files `node`'s declared outputs match on disk now, workspace-relative and sorted. */
29
+ async function outputsLeft(
30
+ node: TaskNode,
31
+ workspaceRoot: string,
32
+ nestedProjectDirs: string[],
33
+ ): Promise<string[]> {
34
+ const declared = node.config.cache?.outputs
35
+ if (declared === undefined) return []
36
+ const [own, ws] = await Promise.all([
37
+ resolveOutputs({ projectDir: node.projectDir, outputs: declared.files, nestedProjectDirs }),
38
+ resolveWorkspaceOutputs({ workspaceRoot, outputs: declared.workspaceFiles ?? [] }),
39
+ ])
40
+ return [...new Set([...own, ...ws].map((p) => relPosix(workspaceRoot, p)))].sort()
49
41
  }
50
42
 
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
43
+ export async function keepRunBytes(
44
+ final: readonly TaskOutcome[],
45
+ args: RunReportArgs,
46
+ ): Promise<RunReport> {
47
+ const { keep, runId, workspaceRoot } = args
48
+ const tasks: RunReportTask[] = []
49
+ const work: (() => Promise<void>)[] = []
50
+ for (const o of final) {
51
+ if (isGroupTask(o.node)) continue
52
+ const task: { -readonly [K in keyof RunReportTask]: RunReportTask[K] } = {
53
+ id: o.node.id,
54
+ status: o.status,
55
+ durationMs: Math.round(o.durationMs),
56
+ ...(o.hash !== undefined ? { key: o.hash } : {}),
57
+ }
58
+ tasks.push(task)
59
+ if (keep === undefined) continue
60
+ const runLog = o.runLog
61
+ if (runLog !== undefined) {
62
+ work.push(async () => {
63
+ const text = decodeOutputLog(runLog)
64
+ .map((c) => c.text)
65
+ .join('')
66
+ if (text.length === 0) return
67
+ const name = runEntryName(runId, o.node.id, 'log')
68
+ if (await keep(name, new TextEncoder().encode(text))) task.log = name
69
+ })
70
+ }
71
+ if (o.status === 'failed' && o.where === undefined) {
72
+ work.push(async () => {
73
+ const bytes = await packRunOutputs(
74
+ workspaceRoot,
75
+ await outputsLeft(
76
+ o.node,
77
+ workspaceRoot,
78
+ args.nestedDirsByProject.get(o.node.projectName) ?? [],
79
+ ),
80
+ )
81
+ if (bytes === null) return
82
+ const name = runEntryName(runId, o.node.id, 'outputs')
83
+ if (await keep(name, bytes)) task.outputs = name
84
+ })
72
85
  }
73
86
  }
74
- return t
75
- }
76
-
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
92
- }
93
- }
94
-
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 '—'
106
- }
107
- }
108
-
109
- function fmtDuration(ms: number): string {
110
- if (ms < 1000) return `${Math.round(ms)}ms`
111
- return `${(ms / 1000).toFixed(2)}s`
112
- }
113
-
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
- }
136
-
137
- const cell = escapeMarkdownCell
138
-
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`)
166
-
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
- )
87
+ for (let i = 0; i < work.length; i += 8) {
88
+ await Promise.all(work.slice(i, i + 8).map((w) => w()))
187
89
  }
188
- return lines.join('\n') + '\n'
90
+ return { runId, commit: args.commit, ciRun: args.ciRun, ok: args.ok, tasks }
189
91
  }
@@ -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,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
+ }