@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
@@ -15,6 +15,8 @@ import os from 'node:os'
15
15
  import path from 'node:path'
16
16
  import { executablePath, gitEnv, relPosix, ulid, xxh3hex } from '../util/index.js'
17
17
  import { resolveSetting } from '../settings.js'
18
+ import { VERSION } from '../version.js'
19
+ import type { RunContextRecord } from './telemetry.js'
18
20
 
19
21
  export interface GitContext {
20
22
  /** `git rev-parse HEAD`, or null outside a repo / on failure. */
@@ -522,3 +524,56 @@ export function currentBranch(
522
524
  const set = resolveSetting('run.branch', workspace, flag, env).value as string | undefined
523
525
  return set ?? captureGitContext(workspaceRoot, null, env).branch
524
526
  }
527
+
528
+ export interface TelemetryRunContextArgs {
529
+ runId: string
530
+ /** The invocation, already masked. */
531
+ command: string
532
+ requestedTasks: readonly string[]
533
+ cachePolicy: string
534
+ concurrency: number
535
+ flow: RunContextRecord['flow']
536
+ git: GitContext
537
+ ci: CiContext
538
+ host: HostContext
539
+ workspaceRoot: string
540
+ /** `remote.origin.url` as the run's enumeration read it; see `captureWorkspaceIdentity`. */
541
+ originUrl: string | null | undefined
542
+ }
543
+
544
+ /**
545
+ * The run-context record telemetry sinks receive: the invocation header's
546
+ * git/CI/host data shaped as the export contract. Paid only when a sink can
547
+ * exist: the workspace identity may write `.vx/cache/workspace-id`.
548
+ */
549
+ export function telemetryRunContext(a: TelemetryRunContextArgs): RunContextRecord {
550
+ const ws = captureWorkspaceIdentity(a.workspaceRoot, a.originUrl)
551
+ const { ci } = a
552
+ return {
553
+ runId: a.runId,
554
+ vxVersion: VERSION,
555
+ command: a.command,
556
+ requestedTasks: [...a.requestedTasks],
557
+ cachePolicy: a.cachePolicy,
558
+ concurrency: a.concurrency,
559
+ flow: a.flow,
560
+ commitSha: a.git.commitSha,
561
+ branch: a.git.branch,
562
+ defaultBranch: captureDefaultBranch(process.env, a.workspaceRoot),
563
+ dirty: a.git.dirty,
564
+ ci: ci.ci,
565
+ ciProvider: ci.provider,
566
+ ...(ci.runUrl !== undefined ? { ciRunUrl: ci.runUrl } : {}),
567
+ ...(ci.change !== undefined ? { ciChange: ci.change } : {}),
568
+ ...(ci.pipeline !== undefined ? { ciPipeline: ci.pipeline } : {}),
569
+ ...(ci.job !== undefined ? { ciJob: ci.job } : {}),
570
+ ...(ci.attempt !== undefined ? { ciAttempt: ci.attempt } : {}),
571
+ host: a.host.host,
572
+ os: a.host.os,
573
+ arch: a.host.arch,
574
+ workspaceId: ws.id,
575
+ workspaceName: ws.name,
576
+ ...(ws.repository !== undefined ? { repository: ws.repository } : {}),
577
+ ...(ws.path !== undefined ? { workspacePath: ws.path } : {}),
578
+ }
579
+ }
@@ -8,9 +8,9 @@ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync
8
8
  import path from 'node:path'
9
9
  import { isGroupTask, type TaskOutcome } from '../graph/index.js'
10
10
  import { decodeOutputLog } from './output-log.js'
11
- import { PASS_STATUSES } from './history.js'
12
- import { splitTaskId } from '../util/index.js'
13
- import { fileLocations, fileMemo, type OutputLocation } from './path-links.js'
11
+ import { PASS_STATUSES } from '../history/index.js'
12
+ import { isFsRefusal, splitTaskId } from '../util/index.js'
13
+ import { fileLocations, fileMemo, type OutputLocation } from '../report/index.js'
14
14
 
15
15
  /** Failed runs whose output is kept; run ids are UUIDv7, so names sort by age. */
16
16
  const KEPT = 50
@@ -78,14 +78,26 @@ function failuresOf(outcomes: readonly TaskOutcome[]): StoredFailure[] {
78
78
  /**
79
79
  * Write the run's failures, when any outcome carries output, and drop all
80
80
  * but the newest `KEPT` files. Atomic: a reader sees the whole file or none.
81
+ * A refused write (a full disk, a read-only cache dir) costs only this
82
+ * output: `warn` says so and the run's verdict stands.
81
83
  */
82
84
  export function writeRunFailures(
83
85
  cacheDir: string,
84
86
  runId: string,
85
87
  outcomes: readonly TaskOutcome[],
88
+ warn: (message: string) => void,
86
89
  ): void {
87
90
  const tasks = failuresOf(outcomes)
88
91
  if (tasks.length === 0) return
92
+ try {
93
+ keep(cacheDir, runId, tasks)
94
+ } catch (err) {
95
+ if (!isFsRefusal(err)) throw err
96
+ warn(`[vx] failed tasks' output not kept: ${err.message} — the verdict above stands`)
97
+ }
98
+ }
99
+
100
+ function keep(cacheDir: string, runId: string, tasks: TaskFailure[]): void {
89
101
  const dir = dirOf(cacheDir)
90
102
  mkdirSync(dir, { recursive: true })
91
103
  const file = path.join(dir, `${runId}.json`)
@@ -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[]
@@ -32,12 +33,13 @@ export interface RunRecordsInput {
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
+ }