@vzn/vx 0.0.596 → 0.0.598

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 (79) hide show
  1. package/launcher.cjs +7 -3
  2. package/package.json +5 -5
  3. package/src/bin.ts +9 -5
  4. package/src/cache/archive.ts +35 -6
  5. package/src/cache/cache.ts +60 -31
  6. package/src/cache/git-inputs.ts +23 -0
  7. package/src/cache/index.ts +2 -0
  8. package/src/cache/inputs.ts +87 -32
  9. package/src/cache/key-fold.ts +1 -1
  10. package/src/cache/layer.ts +2 -1
  11. package/src/cache/layered-cache.ts +14 -9
  12. package/src/cache/output-index.ts +12 -7
  13. package/src/cache/run-history.ts +16 -8
  14. package/src/cli/cache.ts +2 -2
  15. package/src/cli/foreign-flags.ts +56 -2
  16. package/src/cli/info.ts +1 -1
  17. package/src/cli/init.ts +2 -2
  18. package/src/cli/last.ts +6 -3
  19. package/src/cli/lock.ts +5 -3
  20. package/src/cli/plan-format.ts +26 -20
  21. package/src/cli/run.ts +42 -15
  22. package/src/cli/select.ts +19 -5
  23. package/src/cli/show.ts +21 -27
  24. package/src/cli/upgrade.ts +1 -1
  25. package/src/cli/watch-cycle.ts +43 -0
  26. package/src/cli/watch-fs.ts +11 -3
  27. package/src/cli/watch.ts +50 -11
  28. package/src/cli/workspace-config.ts +3 -1
  29. package/src/config.ts +21 -0
  30. package/src/exec/executor.ts +1 -1
  31. package/src/exec/kill-tree.ts +121 -20
  32. package/src/exec/runner.ts +224 -48
  33. package/src/exec/sandbox-paths.ts +11 -0
  34. package/src/exec/sandbox-runtime.ts +187 -54
  35. package/src/exec/sandbox-violations.ts +59 -6
  36. package/src/graph/priorities.ts +1 -2
  37. package/src/graph/scheduler.ts +2 -2
  38. package/src/graph/task-graph.ts +145 -33
  39. package/src/index.ts +3 -1
  40. package/src/orchestrator/affected-tasks.ts +6 -2
  41. package/src/orchestrator/deferred-outputs.ts +3 -2
  42. package/src/orchestrator/doctor.ts +5 -3
  43. package/src/orchestrator/excluded-keys.ts +2 -13
  44. package/src/orchestrator/execute-task.ts +70 -28
  45. package/src/orchestrator/failure-mode.ts +38 -29
  46. package/src/orchestrator/framed-output.ts +5 -5
  47. package/src/orchestrator/hit-restore.ts +1 -1
  48. package/src/orchestrator/index.ts +0 -1
  49. package/src/orchestrator/keyed-projects.ts +6 -3
  50. package/src/orchestrator/lockfile-claim.ts +14 -7
  51. package/src/orchestrator/logger.ts +17 -7
  52. package/src/orchestrator/miss-save.ts +7 -11
  53. package/src/orchestrator/plan.ts +4 -20
  54. package/src/orchestrator/plugin-host.ts +7 -2
  55. package/src/orchestrator/plugin.ts +5 -2
  56. package/src/orchestrator/prepare.ts +4 -2
  57. package/src/orchestrator/projects.ts +1 -1
  58. package/src/orchestrator/run-artifacts.ts +2 -2
  59. package/src/orchestrator/run-context.ts +1 -1
  60. package/src/orchestrator/run-lock.ts +35 -25
  61. package/src/orchestrator/run.ts +2 -1
  62. package/src/orchestrator/sandbox-request.ts +31 -9
  63. package/src/orchestrator/signals.ts +22 -4
  64. package/src/orchestrator/stable-keys.ts +3 -11
  65. package/src/orchestrator/summary.ts +5 -9
  66. package/src/orchestrator/task-hash.ts +13 -3
  67. package/src/orchestrator/upstream.ts +11 -0
  68. package/src/util/hangup.ts +42 -0
  69. package/src/util/index.ts +2 -0
  70. package/src/util/paths.ts +1 -1
  71. package/src/workspace/affected.ts +79 -42
  72. package/src/workspace/config-cache.ts +15 -1
  73. package/src/workspace/config-eval.ts +47 -7
  74. package/src/workspace/config-imports.ts +8 -55
  75. package/src/workspace/config-schema.ts +30 -6
  76. package/src/workspace/lockfile.ts +2 -1
  77. package/src/workspace/migrate-scripts.ts +1 -1
  78. package/src/workspace/project-loader.ts +33 -6
  79. package/src/workspace/workspace.ts +36 -14
@@ -21,34 +21,40 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
21
21
 
22
22
  /**
23
23
  * Per-key outcome projection: every keyed (project, task, hash) under
24
- * `source` (a relation with `project`, `task`, `hash`, `status`, `cache_hit`
25
- * columns) with how often it failed and how often it passed — a pass being
26
- * an executed success or a cache hit, which replays a success. Every
24
+ * `source` (`runs`, or a relation with its `id`, `project`, `task`, `hash`,
25
+ * `status`, `cache_hit` columns) with how often it failed, how often it
26
+ * passed — an executed success or a cache hit, which replays a success —
27
+ * and how often it failed AFTER its first pass (`relapses`). Every
27
28
  * flakiness reader is a filter over this one projection.
28
29
  */
29
30
  function keyOutcomesSql(source: string, where = ''): string {
30
- return `SELECT project, task, hash,
31
- SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures,
32
- SUM(CASE WHEN status = 'success' AND attempts > 1 THEN attempts - 1 ELSE 0 END)
33
- AS retried_failures,
34
- SUM(CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
35
- THEN 1 ELSE 0 END) AS passes
36
- FROM ${source}
37
- WHERE ${KEYED_RUNS_SQL}${where}
31
+ return `SELECT project, task, hash, SUM(failed) AS failures, SUM(passed) AS passes,
32
+ SUM(retried) AS retried_failures,
33
+ SUM(CASE WHEN failed = 1 AND id > first_pass THEN 1 ELSE 0 END) AS relapses
34
+ FROM (SELECT project, task, hash, id, failed, passed, retried,
35
+ MIN(CASE WHEN passed = 1 THEN id END) OVER (PARTITION BY project, task, hash) AS first_pass
36
+ FROM (SELECT project, task, hash, id,
37
+ CASE WHEN status = 'failed' THEN 1 ELSE 0 END AS failed,
38
+ CASE WHEN status = 'success' AND attempts > 1 THEN attempts - 1 ELSE 0 END AS retried,
39
+ CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
40
+ THEN 1 ELSE 0 END AS passed
41
+ FROM ${source}
42
+ WHERE ${KEYED_RUNS_SQL}${where}))
38
43
  GROUP BY project, task, hash`
39
44
  }
40
45
 
41
46
  /**
42
47
  * Per-key outcome subquery: the distinct cache keys under `source` that
43
- * produced BOTH a failure and a success — the definitional flake: identical
44
- * inputs, different outcomes. A failure whose key never succeeded is a
45
- * legitimate break (a changed input that fails), which belongs to the
46
- * regressions surface, not flakiness. One projection of the rule serves the
48
+ * FAILED AFTER THEY HAD PASSED — the definitional flake: identical inputs,
49
+ * a green result, then a red one. A failure whose key never succeeded is a
50
+ * legitimate break (a changed input that fails), and failures that end in
51
+ * a first pass are a recovery (a missing tool installed, a service brought
52
+ * up): neither is flakiness. One projection of the rule serves the
47
53
  * all-time query below and the windowed one in `history.ts`.
48
54
  */
49
55
  export function mixedOutcomeKeysSql(source: string, where = ''): string {
50
56
  return `SELECT project, task FROM (${keyOutcomesSql(source, where)})
51
- WHERE failures > 0 AND passes > 0`
57
+ WHERE relapses > 0`
52
58
  }
53
59
 
54
60
  /** Mixed-outcome keys of one (project, task) over its WHOLE recorded history. */
@@ -63,7 +69,7 @@ export function mixedOutcomeKeyCount(db: Database, project: string, task: string
63
69
 
64
70
  /**
65
71
  * The verdict. Flaky requires a NONDETERMINISM signal: a within-run retry,
66
- * or a cache key that both failed and succeeded (`mixedKeys`). Failures
72
+ * or a cache key that failed after it had passed (`mixedKeys`). Failures
67
73
  * alone — each on its own key — are legitimate breaks, however many there
68
74
  * are. `mixedKeys` is a thunk so a task that never failed is stable without
69
75
  * the key count being computed at all.
@@ -120,9 +126,9 @@ const keyOf = (c: { project: string; task: string; hash: string }): string =>
120
126
 
121
127
  /**
122
128
  * Which of this run's executed tasks are flaky, judged BEFORE the run's own
123
- * rows are recorded: a pass on a key that has failed before, a failure on
124
- * a key that has passed before, or a within-run retry. The counts fold this
125
- * run in.
129
+ * rows are recorded: a failure on a key that has passed before, a pass on a
130
+ * key that already did so, or a within-run retry. A first pass after only
131
+ * failures is a recovery, not a flake. The counts fold this run in.
126
132
  *
127
133
  * The cost follows the run's colour. No candidate (every task a hit or a
128
134
  * skip): no query. A green miss: one probe of the `runs_failed` partial
@@ -148,7 +154,7 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
148
154
  (c) => c.status === 'failed' || c.attempts > 1 || everFailed.has(keyOf(c)),
149
155
  )
150
156
  if (suspects.length === 0) return []
151
- const counts = new Map<string, { passes: number; failures: number }>()
157
+ const counts = new Map<string, { passes: number; failures: number; relapses: number }>()
152
158
  chunked(suspects, (chunk) => {
153
159
  const rows = db
154
160
  .query(keyOutcomesSql('runs', ` AND hash IN (${chunk.map(() => '?').join(', ')})`))
@@ -158,27 +164,30 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
158
164
  hash: string
159
165
  passes: number
160
166
  failures: number
167
+ relapses: number
161
168
  }[]
162
- for (const r of rows) counts.set(keyOf(r), { passes: r.passes, failures: r.failures })
169
+ for (const r of rows)
170
+ counts.set(keyOf(r), { passes: r.passes, failures: r.failures, relapses: r.relapses })
163
171
  })
164
172
  const out: FlakyFinding[] = []
165
173
  for (const c of suspects) {
166
- const before = counts.get(keyOf(c)) ?? { passes: 0, failures: 0 }
174
+ const before = counts.get(keyOf(c)) ?? { passes: 0, failures: 0, relapses: 0 }
175
+ const relapsed = c.status === 'failed' ? before.passes > 0 : before.relapses > 0
176
+ if (!relapsed && c.attempts <= 1) continue
167
177
  const passes = before.passes + (c.status === 'success' ? 1 : 0)
168
178
  const failures = before.failures + (c.status === 'failed' ? 1 : 0)
169
- if (!(passes > 0 && failures > 0) && c.attempts <= 1) continue
170
179
  out.push({ ...c, taskId: `${c.project}#${c.task}`, passes, failures })
171
180
  }
172
181
  return out
173
182
  }
174
183
 
175
- /** One task the recorded history shows both passing and failing on unchanged inputs. */
184
+ /** One task the recorded history shows failing on a key that had passed. */
176
185
  export interface FlakyTask {
177
186
  /** `project#task`. */
178
187
  taskId: string
179
188
  project: string
180
189
  task: string
181
- /** Distinct cache keys that both passed and failed. */
190
+ /** Distinct cache keys that failed after they had passed. */
182
191
  keys: number
183
192
  /** Outcomes over those keys. */
184
193
  passes: number
@@ -186,7 +195,7 @@ export interface FlakyTask {
186
195
  }
187
196
 
188
197
  /**
189
- * Every task with a mixed-outcome key anywhere in the retained history
198
+ * Every task with a key that failed after it had passed anywhere in the retained history
190
199
  * (30 days, `RunHistory.pruneOlderThan`), most failures first: the
191
200
  * doctor's list. A pass that took a retry mixes its key on its own: it is
192
201
  * one `success` row whose failed attempts count as failures (`vx info`
@@ -198,8 +207,8 @@ export function flakyTasks(db: Database): FlakyTask[] {
198
207
  .query(
199
208
  `SELECT project, task, COUNT(*) AS keys, SUM(passes) AS passes, SUM(failures) AS failures
200
209
  FROM (SELECT project, task, passes, failures + retried_failures AS failures
201
- FROM (${keyOutcomesSql('runs')}))
202
- WHERE failures > 0 AND passes > 0
210
+ FROM (${keyOutcomesSql('runs')})
211
+ WHERE relapses > 0 OR retried_failures > 0)
203
212
  GROUP BY project, task
204
213
  ORDER BY failures DESC, passes DESC, project, task`,
205
214
  )
@@ -27,9 +27,9 @@
27
27
 
28
28
  import { withForwardArgs } from '../exec/index.js'
29
29
  import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
30
- import { maskedCommand } from '../util/index.js'
30
+ import { formatElapsed, maskedCommand } from '../util/index.js'
31
31
  import { paint, type ColorSupport } from './colors.js'
32
- import { formatDuration, neverStarted } from './summary.js'
32
+ import { neverStarted } from './summary.js'
33
33
  import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
34
34
 
35
35
  const NO_COLOR: ColorSupport = { enabled: false }
@@ -220,7 +220,7 @@ const CACHE_COL = 8 // "no-cache" / "remote" / "local" / "fresh" / "miss"
220
220
 
221
221
  /** Right-align the duration in a TIME_COL cell (pad on the left). */
222
222
  function timeCell(ms: number | null, colors: ColorSupport): string {
223
- const raw = ms === null ? '' : formatDuration(ms)
223
+ const raw = ms === null ? '' : formatElapsed(ms)
224
224
  return ' '.repeat(Math.max(0, TIME_COL - raw.length)) + paint('', raw, colors, { dim: true })
225
225
  }
226
226
 
@@ -487,7 +487,7 @@ export function formatFrameClose(
487
487
  // both ends of the frame as a long-lived task.
488
488
  const tail =
489
489
  persistent && outcome.status === 'success'
490
- ? ` ${paint('', `(${formatDuration(outcome.durationMs)})`, colors, { dim: true })} ${paint(ACCENT, 'running', colors)}`
490
+ ? ` ${paint('', `(${formatElapsed(outcome.durationMs)})`, colors, { dim: true })} ${paint(ACCENT, 'running', colors)}`
491
491
  : formatBlockFooter(outcome, colors)
492
492
  const close = `${corner('└─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('──')}${tail}`
493
493
  const violations = violationSection(outcome, colors)
@@ -594,7 +594,7 @@ function formatBlockFooter(o: TaskOutcome, colors: ColorSupport): string {
594
594
  // saved" on the strength of it. The stored time lives on
595
595
  // `TaskOutcome.storedDurationMs`.) Status differs by outcome — see
596
596
  // formatStatusTag.
597
- const dur = paint('', `(${formatDuration(o.durationMs)})`, colors, { dim: true })
597
+ const dur = paint('', `(${formatElapsed(o.durationMs)})`, colors, { dim: true })
598
598
  const tag = formatStatusTag(o, colors)
599
599
  return ` ${dur} ${tag}${flakyNote(o, colors)}`
600
600
  }
@@ -130,7 +130,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
130
130
  // set that shrank would otherwise vouch for a tree it never saw,
131
131
  // and the failure it would let through is a skipped restore.
132
132
  const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
133
- setKnown = covers && (await args.cache.outputDirsCurrent!(node.projectDir, dirRows))
133
+ setKnown = covers && (await args.cache.outputDirsCurrent(node.projectDir, dirRows))
134
134
  endDirs()
135
135
  }
136
136
  let treeMatches: boolean
@@ -30,7 +30,6 @@ export { defaultLogger, resolveOutputView } from './logger.js'
30
30
  export { detectColors, paint, type ColorSupport } from './colors.js'
31
31
  export type { Logger, OutputView } from './logger.js'
32
32
  export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
33
- export { formatDuration } from './summary.js'
34
33
  export {
35
34
  createEventBus,
36
35
  wireForwarder,
@@ -93,9 +93,12 @@ export function foldedDeps(node: TaskNode, nodeOf: (id: string) => TaskNode): Fo
93
93
  * the hashes are. An exec task's key folds its own id (`task:<id>`), so its
94
94
  * id is enough; a group's hash is a roll-up of its members' ids and hashes
95
95
  * with no id of its own, so two groups over the same members hash alike.
96
+ * The members are `keyUpstream`'s, which `computeGroupHash` is handed: under
97
+ * `--exclude-dependencies` `node.deps` holds order-only edges and lacks the
98
+ * dropped ones, and two groups the key told apart shared a unit.
96
99
  */
97
100
  function foldUnit(node: TaskNode): string {
98
- return isGroupTask(node) && node.config.cache === undefined
99
- ? `group|${[...node.deps].sort().join('|')}`
100
- : `task|${node.id}`
101
+ if (!isGroupTask(node) || node.config.cache !== undefined) return `task|${node.id}`
102
+ const members = [...keyedDeps(node), ...(node.excludedUpstream ?? []).map((u) => u.node.id)]
103
+ return `group|${members.sort().join('|')}`
101
104
  }
@@ -71,6 +71,7 @@ interface Digests {
71
71
 
72
72
  interface Memo {
73
73
  version: number
74
+ claimant: string
74
75
  lock: string
75
76
  /** `extraFiles` → content hash when the digests were computed. */
76
77
  extras?: Record<string, string>
@@ -120,6 +121,11 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
120
121
  const { file, digest, version } = options
121
122
  const scope = options.scope ?? 'project'
122
123
  const part = options.part ?? 'deps'
124
+ // `version` is one claimant's counter, and another claimant of the file
125
+ // at the same number read this one's memo: one that named no extra file
126
+ // left the other's patch edits out of its key (X-130). The functions'
127
+ // source stands for the claimant; a rebuild that changes it is a miss.
128
+ const claimant = xxh3hex([part, String(digest), String(options.extraFiles ?? '')].join('\0'))
123
129
  if (scope !== 'project' && scope !== 'workspace') {
124
130
  throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
125
131
  }
@@ -159,7 +165,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
159
165
  const memoFile = path.join(cacheDir, 'lockfile-claims', `${file}.json`)
160
166
  // The extra files the last digest read, and their hashes now: a warm run
161
167
  // re-hashes the files the memo names and never parses to learn them.
162
- const memo = await readMemo(memoFile, version, lockHash)
168
+ const memo = await readMemo(memoFile, version, claimant, lockHash)
163
169
  let extras: Record<string, string> = {}
164
170
  let extraStats: Record<string, string> = {}
165
171
  let importers: ReadonlyMap<string, string> | undefined
@@ -182,8 +188,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
182
188
  ? new Map<string, string>()
183
189
  : await computeAndMemo(
184
190
  memoFile,
185
- version,
186
- lockHash,
191
+ { version, claimant, lock: lockHash },
187
192
  extras,
188
193
  digest(text, new Map(Object.entries(extras))),
189
194
  )
@@ -273,11 +278,14 @@ function importerOf(workspaceRoot: string, projectDir: string): string {
273
278
  async function readMemo(
274
279
  memoFile: string,
275
280
  version: number,
281
+ claimant: string,
276
282
  lock: string,
277
283
  ): Promise<{ extras: Record<string, string>; importers: Map<string, string> } | undefined> {
278
284
  try {
279
285
  const memo = (await Bun.file(memoFile).json()) as Memo
280
- if (memo.version !== version || memo.lock !== lock) return undefined
286
+ if (memo.version !== version || memo.claimant !== claimant || memo.lock !== lock) {
287
+ return undefined
288
+ }
281
289
  return { extras: memo.extras ?? {}, importers: new Map(Object.entries(memo.importers)) }
282
290
  } catch {
283
291
  return undefined
@@ -286,12 +294,11 @@ async function readMemo(
286
294
 
287
295
  async function computeAndMemo(
288
296
  memoFile: string,
289
- version: number,
290
- lock: string,
297
+ identity: Pick<Memo, 'version' | 'claimant' | 'lock'>,
291
298
  extras: Record<string, string>,
292
299
  importers: ReadonlyMap<string, string>,
293
300
  ): Promise<ReadonlyMap<string, string>> {
294
- const memo: Memo = { version, lock, extras, importers: Object.fromEntries(importers) }
301
+ const memo: Memo = { ...identity, extras, importers: Object.fromEntries(importers) }
295
302
  // Write-then-rename: a reader never sees a half-written memo, and two
296
303
  // concurrent runs each land a whole one.
297
304
  const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
@@ -19,9 +19,16 @@ import {
19
19
  type StatusStream,
20
20
  type WorkerSlot,
21
21
  } from './status-line.js'
22
- import { formatDuration, formatSummarySection, neverStarted, type RunContext } from './summary.js'
22
+ import { formatSummarySection, neverStarted, type RunContext } from './summary.js'
23
23
  import { isGroupTask } from '../graph/index.js'
24
- import { appendTail, createTail, resetTail, tailText, type Tail } from '../util/index.js'
24
+ import {
25
+ appendTail,
26
+ createTail,
27
+ formatElapsed,
28
+ resetTail,
29
+ tailText,
30
+ type Tail,
31
+ } from '../util/index.js'
25
32
  import { isCacheHit } from './telemetry.js'
26
33
  import { failedLabel, outcomeWord, ranNoCache } from './events.js'
27
34
 
@@ -511,9 +518,11 @@ export function defaultLogger(
511
518
  killStatus()
512
519
  // Persistent tails first: they are context for whatever ran against
513
520
  // the server, so they read above the failures they explain. `none`
514
- // and `errors-only` state their contracts absolutely (no per-task
515
- // output / only failed tasks print), so they stay silent — the tail
516
- // is still captured and still bounded, just never printed. Guarded
521
+ // prints no per-task output and `errors-only` only failed tasks: a
522
+ // server that died on its own before the run stopped it is failed by
523
+ // now (run.ts sets its outcome in place), and under `errors-only` its
524
+ // tail was dropped with the healthy ones', so the crash read only as a
525
+ // count (WD-25). The rest is still captured and bounded. Guarded
517
526
  // like the failures below: run() calls runEnd twice on the success
518
527
  // path (once before the summary, once in its finally), and though the
519
528
  // bus delivers `run:end` once (`busLogger`), a renderer an embedder
@@ -521,12 +530,13 @@ export function defaultLogger(
521
530
  if (flushedPersistent) flushKept()
522
531
  if (!flushedPersistent) {
523
532
  flushedPersistent = true
524
- if (view.mode !== 'none' && view.mode !== 'errors-only') {
533
+ if (view.mode !== 'none') {
525
534
  for (const t of persistentTails.values()) {
526
535
  // No outcome means the task never completed — it is still
527
536
  // pre-ready, so there is no "since ready" window to render and
528
537
  // its buffered output belongs to the frame it never got.
529
538
  if (t.outcome === undefined) continue
539
+ if (view.mode === 'errors-only' && t.outcome.status !== 'failed') continue
530
540
  emitBlock(
531
541
  fenced(
532
542
  formatPersistentTailBlock(
@@ -880,7 +890,7 @@ export function defaultLogger(
880
890
  } else {
881
891
  emitBlock(
882
892
  `::group::${ghaData(
883
- `${node.id} (${outcomeWord(outcome)} ${formatDuration(outcome.durationMs)})`,
893
+ `${node.id} (${outcomeWord(outcome)} ${formatElapsed(outcome.durationMs)})`,
884
894
  )}\n${body}::endgroup::\n`,
885
895
  )
886
896
  }
@@ -148,11 +148,9 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
148
148
  // `cache.outputs` is not a write grant; `exec.sandbox.allow.write` is.
149
149
  const sandboxed = node.config.exec?.sandbox !== undefined
150
150
  const grantsWrite = (node.config.exec?.sandbox?.allow?.write?.length ?? 0) > 0
151
- // The workspace side too: a `workspaceFiles` directory linked out of
152
- // the workspace drops every file the same way (M-65).
153
- const linkedOut =
154
- outputDirLinkedOut(node.projectDir, a.outputs, 'the project') ??
155
- outputDirLinkedOut(a.workspaceRoot, a.wsOutputs, 'the workspace')
151
+ // A `workspaceFiles` directory linked out of the workspace drops every
152
+ // file (M-65); a project one is refused by the resolver (X-88).
153
+ const linkedOut = outputDirLinkedOut(a.workspaceRoot, a.wsOutputs)
156
154
  log.status(
157
155
  `[vx] ${node.id}: cache.outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
158
156
  `an empty artifact is saved; a later hit restores nothing` +
@@ -300,15 +298,13 @@ function markWritten(
300
298
  }
301
299
 
302
300
  /**
303
- * The first output directory a pattern names that is a symlink resolving
304
- * outside `baseDir` (the project, or the workspace root for
305
- * `workspaceFiles`): every file under it is dropped as outside, and the
306
- * empty-artifact warning blamed the glob (M-61, M-65).
301
+ * The first `workspaceFiles` directory that is a symlink resolving outside
302
+ * the workspace root: every file under it is dropped as outside, and the
303
+ * empty-artifact warning blamed the glob (M-65).
307
304
  */
308
305
  function outputDirLinkedOut(
309
306
  baseDir: string,
310
307
  outputs: readonly string[],
311
- base: string,
312
308
  ): { dir: string; target: string; base: string } | undefined {
313
309
  const realProject = realpathOrNull(baseDir) ?? baseDir
314
310
  // `dist` names the tree `dist/**`: the same reading the resolver gives.
@@ -326,7 +322,7 @@ function outputDirLinkedOut(
326
322
  }
327
323
  const target = realpathOrNull(abs)
328
324
  if (target !== null && target !== realProject && !target.startsWith(realProject + path.sep)) {
329
- return { dir, target, base }
325
+ return { dir, target, base: 'the workspace' }
330
326
  }
331
327
  }
332
328
  }
@@ -14,16 +14,10 @@
14
14
  import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
15
15
  import { cachesNothing, FULL_CACHE_POLICY } from '../cache/index.js'
16
16
  import { ranNoCache } from './events.js'
17
- import {
18
- idleServers,
19
- isGroupTask,
20
- runGraph,
21
- type TaskNode,
22
- type TaskOutcome,
23
- } from '../graph/index.js'
17
+ import { idleServers, isGroupTask, runGraph, type TaskNode } from '../graph/index.js'
24
18
  import type { HistoryProvider } from './history.js'
25
19
  import { computeGroupKey, computeTaskHash } from './task-hash.js'
26
- import { keyUpstream } from './upstream.js'
20
+ import { keyedOutcome, keyUpstream } from './upstream.js'
27
21
 
28
22
  export type CacheStatus =
29
23
  | 'hit-local' // entry exists in local cache
@@ -157,7 +151,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
157
151
  }
158
152
  if (isGroupTask(node)) {
159
153
  cacheStatusById.set(node.id, 'group')
160
- return planOutcome(node, await computeGroupKey(hashArgs))
154
+ return keyedOutcome(node, await computeGroupKey(hashArgs))
161
155
  }
162
156
 
163
157
  const hash = await computeTaskHash(hashArgs)
@@ -180,7 +174,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
180
174
  status = where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local'
181
175
  }
182
176
  cacheStatusById.set(node.id, status)
183
- return planOutcome(node, hash)
177
+ return keyedOutcome(node, hash)
184
178
  },
185
179
  })
186
180
 
@@ -303,13 +297,3 @@ function predictPlan(tasks: PlannedTask[]): PlanPrediction {
303
297
  }
304
298
  return { wallMs, workMs, unknownCount }
305
299
  }
306
-
307
- function planOutcome(node: TaskNode, hash: string | undefined): TaskOutcome {
308
- return {
309
- node,
310
- status: 'success',
311
- exitCode: 0,
312
- durationMs: 0,
313
- ...(hash !== undefined ? { hash } : {}),
314
- }
315
- }
@@ -10,7 +10,7 @@
10
10
  import path from 'node:path'
11
11
  import { ChainedCache, type CacheLayer } from '../cache/index.js'
12
12
  import { localExecutor, type TaskExecutor } from '../exec/index.js'
13
- import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
13
+ import { maskedLine, settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
14
14
  import type { ProjectConfig, WorkspaceConfig, WorkspaceRules } from '../config.js'
15
15
  import { checkGraph, type TaskNode } from '../graph/index.js'
16
16
  import type {
@@ -86,8 +86,13 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
86
86
  try {
87
87
  return await fn()
88
88
  } catch (err) {
89
+ // Masked here, not by whoever prints it: an embedder of `run()` and
90
+ // `vx mcp` print it as they get it, and the throw quotes what the
91
+ // plugin was told (a remote's reply, a header) (L-11).
89
92
  throw new UserError(
90
- `plugin '${plugin.name}' failed in ${hook}: ${err instanceof Error ? err.message : String(err)}`,
93
+ maskedLine(
94
+ `plugin '${plugin.name}' failed in ${hook}: ${err instanceof Error ? err.message : String(err)}`,
95
+ ),
91
96
  )
92
97
  }
93
98
  }
@@ -15,7 +15,7 @@ import type { Cache, CacheLayer, CachePolicy } from '../cache/index.js'
15
15
  import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
16
16
  import type { TaskExecutor } from '../exec/index.js'
17
17
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
18
- import { nearest, UserError } from '../util/index.js'
18
+ import { maskedLine, nearest, UserError } from '../util/index.js'
19
19
  import type { ProjectMeta } from '../workspace/index.js'
20
20
  import type { EventBus, RunStartInfo } from './events.js'
21
21
  import type { TelemetryContext, TelemetrySink } from './telemetry.js'
@@ -716,7 +716,10 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
716
716
  for (const d of disposers) d()
717
717
  throw new PluginSetupError(
718
718
  plugin,
719
- `plugin '${plugin.name}' failed in setup: ${err instanceof Error ? err.message : String(err)}`,
719
+ // Masked at the source, as plugin-host.ts's `safe()` does (L-11).
720
+ maskedLine(
721
+ `plugin '${plugin.name}' failed in setup: ${err instanceof Error ? err.message : String(err)}`,
722
+ ),
720
723
  )
721
724
  }
722
725
  }
@@ -379,9 +379,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
379
379
  // it, and the run said no project did (C-3). Only what is still
380
380
  // unjudged pays for the rest of the workspace.
381
381
  unresolvedTasks = undeclaredIn(unresolvedTasks, projects)
382
+ // By name, not by count: a config-less project in the scope counts as
383
+ // loaded, and a changed one hid an unloaded `test` declarer (X-129).
382
384
  if (
383
- projects.size < projectsWithConfigs.length &&
384
- unresolvedTasks.some((t) => !t.includes('#'))
385
+ unresolvedTasks.some((t) => !t.includes('#')) &&
386
+ projectsWithConfigs.some((m) => !projects.has(m.name))
385
387
  ) {
386
388
  unresolvedTasks = await declaredNowhere(unresolvedTasks, () =>
387
389
  loadProjects({ ...loadArgs, seeds: 'all', closure: false, staged: projects }),
@@ -321,7 +321,7 @@ const DEFAULT_BUILD: TaskConfig = Object.freeze({
321
321
  inputs: Object.freeze({ files: Object.freeze(['**']) }),
322
322
  outputs: Object.freeze({ files: Object.freeze([]) }),
323
323
  }),
324
- }) as unknown as TaskConfig
324
+ })
325
325
 
326
326
  /** The default `build`, which a menu of the tasks a user declared leaves out. */
327
327
  export function isDefaultBuild(task: TaskConfig | undefined): boolean {
@@ -94,8 +94,8 @@ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
94
94
  // task that could never hit (its `hash` is still set — dependents fold
95
95
  // it). Present only when true, so every other row is byte-identical.
96
96
  ...(ranNoCache(o) ? { noCache: true } : {}),
97
- // Present only when the run proved the task flaky: this exact key has
98
- // both passed and failed on record (this run counted), or the task
97
+ // Present only when the run proved the task flaky: this exact key
98
+ // failed after it had passed (this run counted), or the task
99
99
  // needed a retry. A consumer gating on `failed` can tell a break from a
100
100
  // flake without the history; every other row is byte-identical.
101
101
  ...(flaky !== undefined
@@ -379,7 +379,7 @@ function ciLinks(provider: string, env: Record<string, string | undefined>): CiL
379
379
  default:
380
380
  return {}
381
381
  }
382
- return Object.fromEntries(Object.entries(links).filter(([, x]) => x !== undefined)) as CiLinks
382
+ return Object.fromEntries(Object.entries(links).filter(([, x]) => x !== undefined))
383
383
  }
384
384
 
385
385
  /** Host name (null on failure) + platform + arch. */