@vzn/vx 0.0.652 → 0.0.653

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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
@@ -1,6 +1,6 @@
1
1
  import { lstatSync } from 'node:fs'
2
2
  import path from 'node:path'
3
- import type { TaskConfig } from '../config.js'
3
+ import type { InputStability, TaskConfig } from '../config.js'
4
4
  import type { FoldMemo, ProjectFilesCache, WorkspaceFilesCache } from '../cache/index.js'
5
5
  import {
6
6
  ABSENT_INPUT,
@@ -9,19 +9,27 @@ import {
9
9
  behindLink,
10
10
  filesToken,
11
11
  foldHead,
12
+ hashFileSeen,
12
13
  type InputListing,
13
14
  type CacheKeyInput,
14
15
  type CacheLayer,
15
16
  FILE_HASH_RACY_MS,
16
17
  racyWindowMs,
17
18
  resolveInputs,
19
+ stampGrainMs,
18
20
  type ResolveInputsArgs,
19
21
  type GitFilesCache,
20
22
  type TaskInputComponent,
21
23
  WORKSPACE_OUTPUT_PREFIX,
22
24
  } from '../cache/index.js'
23
25
  import type { InputFile, TaskInputs } from '../exec/index.js'
24
- import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
26
+ import {
27
+ isGroupTask,
28
+ KeyFailed,
29
+ standInsOf,
30
+ type TaskNode,
31
+ type TaskOutcome,
32
+ } from '../graph/index.js'
25
33
  import { GROUP_VIA } from '../history/index.js'
26
34
  import {
27
35
  isUserError,
@@ -32,6 +40,7 @@ import {
32
40
  UserError,
33
41
  xxh3hex,
34
42
  } from '../util/index.js'
43
+ import { outputsLeft } from './run-report.js'
35
44
  import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
36
45
 
37
46
  /**
@@ -63,8 +72,41 @@ export interface HashCache {
63
72
  graph?: string | undefined
64
73
  }
65
74
 
66
- export function createHashCache(folds?: FoldMemo): HashCache {
67
- return {
75
+ /**
76
+ * `.vx/workspace`'s `outputs.clean`, after the config stage, by the run's
77
+ * hash cache: a task without its own runs under it, so its key folds it
78
+ * (`hashableConfig`). Beside `HashCache`, not on it: that type is the
79
+ * package's surface.
80
+ */
81
+ const workspaceClean = new WeakMap<HashCache, boolean>()
82
+
83
+ /** The workspace's `outputs.clean` the run's keys were derived under. */
84
+ export function workspaceCleanOf(hashCache: HashCache): boolean | undefined {
85
+ return workspaceClean.get(hashCache)
86
+ }
87
+
88
+ /**
89
+ * The `inputs.stability.strategy` of `VX_INPUTS_STABILITY_STRATEGY` (`run`,
90
+ * beating a task's own) and of `.vx/workspace` (beaten by it), by the run's
91
+ * hash cache: it decides which saves are kept, so a key folds the one a
92
+ * task runs under (`hashableConfig`). Beside `HashCache` as the clean is.
93
+ */
94
+ const inputStrategies = new WeakMap<HashCache, InputStrategies>()
95
+
96
+ type InputStrategy = InputStability['strategy']
97
+ type InputStrategies = { run?: InputStrategy; workspace?: InputStrategy }
98
+
99
+ /** The run-level `inputs.stability.strategy` values the run's keys were derived under. */
100
+ export function inputStrategiesOf(hashCache: HashCache): InputStrategies | undefined {
101
+ return inputStrategies.get(hashCache)
102
+ }
103
+
104
+ export function createHashCache(
105
+ folds?: FoldMemo,
106
+ cleanOutputs?: boolean,
107
+ strategies?: InputStrategies,
108
+ ): HashCache {
109
+ const hashCache: HashCache = {
68
110
  taskConfig: new WeakMap(),
69
111
  runtime: new Map(),
70
112
  workspaceRuntime: new Map(),
@@ -72,6 +114,10 @@ export function createHashCache(folds?: FoldMemo): HashCache {
72
114
  projectFiles: new Map(),
73
115
  folds,
74
116
  }
117
+ if (cleanOutputs !== undefined) workspaceClean.set(hashCache, cleanOutputs)
118
+ if (strategies?.run !== undefined || strategies?.workspace !== undefined)
119
+ inputStrategies.set(hashCache, strategies)
120
+ return hashCache
75
121
  }
76
122
 
77
123
  export interface ComputeHashArgs {
@@ -99,6 +145,15 @@ export interface ComputeHashArgs {
99
145
  probesAfterWrites?: ReadonlySet<string>
100
146
  }
101
147
 
148
+ /** A key derivation whose throw is the task's `KeyFailed`: its command never ran. */
149
+ export async function keyOf(derived: Promise<string>): Promise<string> {
150
+ try {
151
+ return await derived
152
+ } catch (err) {
153
+ throw new KeyFailed(err)
154
+ }
155
+ }
156
+
102
157
  /**
103
158
  * Resolve every input the cache key depends on (file content, env
104
159
  * values, upstream hashes, config bytes, forwardArgs) and ask the cache layer to combine them. Used by both
@@ -174,6 +229,12 @@ export interface InputFact {
174
229
  path: string
175
230
  digest: string
176
231
  since: number
232
+ /**
233
+ * The ctime of the stat its digest came from, for a file hashed at the
234
+ * describe (an index OID was not stat'ed): a ctime still equal to it is
235
+ * not a write since, though the command's start shares its coarse tick.
236
+ */
237
+ ctimeMs?: number
177
238
  /**
178
239
  * For a link, the content behind it (`behindLink`), which the key folds
179
240
  * after the link's own target string. An edit there leaves the link
@@ -196,14 +257,21 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
196
257
  inputs: TaskInputs
197
258
  facts: InputFact[]
198
259
  describedAt: number
260
+ /** Lists `inputs.upstream` with a non-cacheable upstream's outputs on disk; before any read of it. */
261
+ readUpstream: () => Promise<void>
199
262
  /** An input file the key did not fold that exists now (see `addedInput`). */
200
263
  added: () => string | undefined
201
264
  }> {
202
265
  const hashedAt = Date.now()
203
266
  const { input, listings } = await resolveKeyInput(args)
204
267
  const sorted = [...input.inputFiles].sort()
268
+ const ctimes = new Map<string, number>()
205
269
  const digests = await Promise.all(
206
- sorted.map((f) => input.fileHashes?.get(f) ?? args.cache.hashFile(f).catch(absentOr)),
270
+ sorted.map(
271
+ (f) =>
272
+ input.fileHashes?.get(f) ??
273
+ hashFileSeen(args.cache, f, (c) => ctimes.set(f, c)).catch(absentOr),
274
+ ),
207
275
  )
208
276
  // Learned BEFORE the key folds it: an edit in between then reads as a
209
277
  // move at the re-check (a withheld save), never as content the key
@@ -229,6 +297,8 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
229
297
  digest: digests[i]!,
230
298
  since: input.fileHashes?.has(f) === true ? indexedAt : hashedAt,
231
299
  }
300
+ const ctimeMs = ctimes.get(f)
301
+ if (ctimeMs !== undefined) fact.ctimeMs = ctimeMs
232
302
  const linked = behind.get(f)
233
303
  if (linked !== undefined) fact.behind = linked
234
304
  return fact
@@ -241,22 +311,44 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
241
311
  a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0,
242
312
  )
243
313
  // Output lists come from the local index — one SELECT for every upstream
244
- // entry. An upstream with no entry (non-cacheable) contributes an empty list.
314
+ // entry. A non-cacheable upstream keeps no entry: its list is its
315
+ // declared outputs as they are on disk now (`readUpstream`), as it ran
316
+ // here and a local dependant reads them here. Left empty, a remote
317
+ // executor shipped the dependant without them and its result was saved
318
+ // under the key.
245
319
  // Read on first access: the local floor never reads it, and its every
246
320
  // miss paid a query over the whole closure (execute-task reads it up
247
321
  // front for a plugin executor).
248
- const listUpstream = (): TaskInputs['upstream'] => {
322
+ const listUpstream = (onDisk?: ReadonlyMap<string, string[]>): TaskInputs['upstream'] => {
249
323
  const rows = args.cache.loadOutputFilesBatch(graft.map((g) => g.hash))
250
324
  return graft.map((g) => {
251
- const outputs = (rows.get(g.hash) ?? []).map((r) =>
252
- r.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
253
- ? r.path.slice(WORKSPACE_OUTPUT_PREFIX.length)
254
- : relPosix(input.workspaceRoot, path.join(g.projectDir, r.path)),
255
- )
325
+ const outputs =
326
+ onDisk?.get(g.taskId) ??
327
+ (rows.get(g.hash) ?? []).map((r) =>
328
+ r.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
329
+ ? r.path.slice(WORKSPACE_OUTPUT_PREFIX.length)
330
+ : relPosix(input.workspaceRoot, path.join(g.projectDir, r.path)),
331
+ )
256
332
  return { taskId: g.taskId, hash: g.hash, outputs }
257
333
  })
258
334
  }
259
335
  let upstream: TaskInputs['upstream'] | undefined
336
+ const readUpstream = async (): Promise<void> => {
337
+ if (upstream !== undefined) return
338
+ const uncached = expandGroupUpstream(args.upstream).filter(
339
+ (u) => u.hash !== undefined && u.node.config.cache !== true,
340
+ )
341
+ // No nested project is cut out: each file found is on this disk, where
342
+ // a local run of the dependant reads it too.
343
+ const onDisk = new Map(
344
+ await Promise.all(
345
+ uncached.map(
346
+ async (u) => [u.node.id, await outputsLeft(u.node, input.workspaceRoot, [])] as const,
347
+ ),
348
+ ),
349
+ )
350
+ upstream ??= listUpstream(onDisk)
351
+ }
260
352
  // A listing from the run's enumeration is as old as it; one listed with
261
353
  // no run cache was listed during this describe.
262
354
  const listedAt = args.gitFilesCache === undefined ? hashedAt : indexedAt
@@ -264,6 +356,7 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
264
356
  hash,
265
357
  facts,
266
358
  describedAt: hashedAt,
359
+ readUpstream,
267
360
  added: () => addedInput(listings, new Set(input.inputFiles), listedAt),
268
361
  inputs: {
269
362
  files,
@@ -287,11 +380,15 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
287
380
  * a file whose ctime is older than its fact (by the racy window, as git
288
381
  * judges its index) has not been written since, and one that is not gets
289
382
  * hashed again and compared. ctime, because no writer can set it back. A
290
- * file that is gone has moved. One written at or after `commandFrom` (the
291
- * describe just before the command) has moved whatever it holds now: an
292
- * input changed and changed BACK while the command ran matched its digest
293
- * again, and the entry saved the other content's output under the key (item
294
- * 1015). A write before it — an upstream's — is judged by content, as before.
383
+ * file that is gone has moved. One written after `commandFrom` (read off
384
+ * the file system's clock, `fsClockNow`, just after the describe) has moved
385
+ * whatever it holds now: an input changed and changed BACK while the
386
+ * command ran matched its digest again, and the entry saved the other
387
+ * content's output under the key (item 1015). A write before it — an
388
+ * upstream's — is judged by content, as before. A coarse clock dates a
389
+ * write in `commandFrom`'s own tick at it, before or after: such a ctime is
390
+ * the upstream's when the describe's stat saw it already
391
+ * (`InputFact.ctimeMs`), else the command's.
295
392
  * `byBytes` (`inputs.stability.strategy: 'mutable'`) judges a write during
296
393
  * the command by content too, when it answers true for that file and its
297
394
  * ctime: the task says the same bytes are its own identical rewrite
@@ -318,9 +415,14 @@ export async function movedInput(
318
415
  // Back since the key folded it gone.
319
416
  if (f.digest === ABSENT_INPUT) return f.path
320
417
  // A whole-second stamp may stand for any write in its second (two for
321
- // an even one, FAT's tick), one during the command included
322
- // (`racyWindowMs`).
323
- if (commandFrom !== undefined && ctimeMs >= commandFrom - racyWindowMs(ctimeMs, 0)) {
418
+ // an even one, FAT's tick), one during the command included. No lag
419
+ // widens it: `commandFrom` is on the file system's clock already.
420
+ const grainMs = stampGrainMs(ctimeMs)
421
+ if (
422
+ commandFrom !== undefined &&
423
+ (ctimeMs > commandFrom ||
424
+ (ctimeMs >= commandFrom - grainMs && (grainMs > 0 || f.ctimeMs !== ctimeMs)))
425
+ ) {
324
426
  if (byBytes?.(f.path, ctimeMs) !== true) return f.path
325
427
  suspects.push(f)
326
428
  continue
@@ -340,14 +442,27 @@ export async function movedInput(
340
442
  return undefined
341
443
  }
342
444
 
343
- /** A task's `filesToken`, as its key's fold memo probe takes it. */
445
+ /**
446
+ * A task's `filesToken`, as its key's fold memo probe takes it: the fields
447
+ * of `inputsArgs` the token reads, and no more. Built through `inputsArgs`,
448
+ * two spread objects per call were ~3 ms of 3,202 (2026-11-07).
449
+ */
344
450
  export function taskFilesToken(
345
- args: Pick<
346
- ComputeHashArgs,
347
- 'node' | 'workspaceRoot' | 'nestedProjectDirs' | 'gitFilesCache' | 'hashCache'
348
- >,
451
+ args: Pick<ComputeHashArgs, 'node' | 'workspaceRoot' | 'nestedProjectDirs'> & {
452
+ gitFilesCache: GitFilesCache
453
+ },
349
454
  ): string | undefined {
350
- return filesToken(inputsArgs({ ...args, upstream: [], cache: undefined as never }))
455
+ const node = args.node
456
+ const out = node.config.outputs
457
+ return filesToken({
458
+ projectDir: node.projectDir,
459
+ workspaceRoot: args.workspaceRoot,
460
+ inputs: node.config.inputs,
461
+ ownOutputs: out?.files ?? [],
462
+ ownWorkspaceOutputs: out?.workspaceFiles ?? [],
463
+ nestedProjectDirs: args.nestedProjectDirs,
464
+ gitFilesCache: args.gitFilesCache,
465
+ })
351
466
  }
352
467
 
353
468
  /** What `resolveInputs` is asked for a task's key. */
@@ -397,6 +512,7 @@ async function resolveKeyInput(
397
512
  cfg.inputs?.tasks,
398
513
  args.node.projectName,
399
514
  args.node.id,
515
+ standInsOf(args.node),
400
516
  )
401
517
  const upstreamHashes = upstreamPairs.map(([, hash]) => hash)
402
518
  // hash → upstream task id, for capture-row naming only (not folded).
@@ -487,7 +603,7 @@ export function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
487
603
  if (hashCache) {
488
604
  const cached = hashCache.taskConfig.get(cfg)
489
605
  if (cached !== undefined) return cached
490
- const hash = xxh3hex(JSON.stringify(hashableConfig(cfg)))
606
+ const hash = xxh3hex(JSON.stringify(hashableConfig(asRun(cfg, hashCache))))
491
607
  hashCache.taskConfig.set(cfg, hash)
492
608
  return hash
493
609
  }
@@ -495,7 +611,36 @@ export function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
495
611
  }
496
612
 
497
613
  /**
498
- * Project the config for hashing: `exec.remote` is a pure PLACEMENT hint
614
+ * The config as the task runs it, where a run-level value changes what is
615
+ * saved: folded as the task's own field would be, and only then, so every
616
+ * key that never ran under one stays.
617
+ *
618
+ * A workspace `outputs.clean: false` saves whatever the outputs held before
619
+ * the command, a leftover included: a key that missed it hit that entry
620
+ * once the clean was back on. An `inputs.stability.strategy` other than
621
+ * `'immutable'` keeps a save the check withholds (`'unchecked'`, one whose
622
+ * input moved under the command): unfolded, that entry was hit once the
623
+ * check was back on, and a formatter it covered never ran.
624
+ */
625
+ function asRun(cfg: TaskConfig, hashCache: HashCache): TaskConfig {
626
+ let out = cfg
627
+ if (workspaceClean.get(hashCache) === false && cfg.outputs && cfg.outputs.clean === undefined) {
628
+ out = { ...out, outputs: { ...cfg.outputs, clean: false } }
629
+ }
630
+ const strategies = inputStrategies.get(hashCache)
631
+ const inputs = cfg.inputs
632
+ if (strategies !== undefined && inputs !== undefined && cfg.exec !== undefined) {
633
+ const own = inputs.stability?.strategy
634
+ const runs = strategies.run ?? own ?? strategies.workspace ?? 'immutable'
635
+ if (runs !== (own ?? 'immutable')) {
636
+ out = { ...out, inputs: { ...inputs, stability: { ...inputs.stability, strategy: runs } } }
637
+ }
638
+ }
639
+ return out
640
+ }
641
+
642
+ /**
643
+ * Project the config for hashing: `exec.remote` (and `exec.worker`) is a pure PLACEMENT hint
499
644
  * with zero effect on outputs, so it is stripped — pinning a task to this
500
645
  * machine never busts a cache. It must not: the whole contract of a remote
501
646
  * executor is that the same command over the same inputs produces the
@@ -521,12 +666,24 @@ function hashableConfig(cfg: TaskConfig): unknown {
521
666
  // writes a config the lock never sorted (2026-10-25).
522
667
  const env = cfg.inputs?.env
523
668
  const pass = cfg.exec?.env?.passThrough
524
- if (cfg.exec?.remote === undefined && sortedNames(env) && sortedNames(pass))
669
+ const secret = cfg.exec?.env?.secret
670
+ if (
671
+ cfg.exec?.remote === undefined &&
672
+ cfg.exec?.worker === undefined &&
673
+ sortedNames(env) &&
674
+ sortedNames(pass) &&
675
+ sortedNames(secret)
676
+ )
525
677
  return keysSorted(cfg)
526
- const { remote: _remote, ...execRest } = cfg.exec ?? ({} as NonNullable<TaskConfig['exec']>)
678
+ const {
679
+ remote: _remote,
680
+ worker: _worker,
681
+ ...execRest
682
+ } = cfg.exec ?? ({} as NonNullable<TaskConfig['exec']>)
527
683
  // Env names are sets: each value is read by its name, so their order
528
684
  // keys nothing (conformance env/declared-order-irrelevant).
529
685
  if (!sortedNames(pass)) execRest.env = { ...execRest.env, passThrough: [...pass!].sort() }
686
+ if (!sortedNames(secret)) execRest.env = { ...execRest.env, secret: [...secret!].sort() }
530
687
  const out: TaskConfig = cfg.exec === undefined ? { ...cfg } : { ...cfg, exec: execRest }
531
688
  if (!sortedNames(env)) out.inputs = { ...cfg.inputs!, env: [...env!].sort() }
532
689
  return keysSorted(out)
@@ -12,7 +12,7 @@
12
12
  // See docs/design/observability-architecture-2026-06.md §1, §3.
13
13
 
14
14
  import type { EventBus } from '../report/index.js'
15
- import type { TaskNode } from '../graph/index.js'
15
+ import type { TaskNode, TaskOutcome } from '../graph/index.js'
16
16
  import type {
17
17
  RunContextRecord,
18
18
  RunSummaryRecord,
@@ -31,6 +31,8 @@ export interface TelemetryHandle {
31
31
  emitSummary(summary: RunSummaryRecord): void
32
32
  /** Await every sink's flush (crash-isolated, time-bounded by the sink), each as a `<plugin>:telemetry` step. */
33
33
  flush(step?: <T>(id: string, work: () => Promise<T>) => Promise<T>): Promise<void>
34
+ /** Send the held `task.end` of each server not in `keep`; see `TelemetrySource.endServers`. */
35
+ endServers(final: readonly TaskOutcome[], keep?: ReadonlySet<string>): void
34
36
  /** Remove the bus subscription. Idempotent. */
35
37
  dispose(): void
36
38
  /** Sample a running task's process tree; see `TelemetrySource.track`. */
@@ -47,12 +49,17 @@ const RECORD_KINDS: Record<TelemetryRecord['kind'], true> = {
47
49
  'run.end': true,
48
50
  }
49
51
 
50
- /** Reject anything that is not sink-shaped, naming what arrived. */
52
+ /**
53
+ * Reject anything that is not sink-shaped, naming what arrived, and return
54
+ * a copy of its members read once here. The sink is user input: a getter
55
+ * read again downstream, outside this try, threw past every guard and
56
+ * failed a green run.
57
+ */
51
58
  function checkSink(sink: unknown): TelemetrySink {
52
59
  if (typeof sink !== 'object' || sink === null) {
53
60
  throw new Error(`telemetry sink must be an object, got ${sink === null ? 'null' : typeof sink}`)
54
61
  }
55
- const wants = (sink as TelemetrySink).wants
62
+ const { name, wants, onRecord, onRunSummary, flush } = sink as TelemetrySink
56
63
  if (wants !== undefined && !Array.isArray(wants)) {
57
64
  throw new Error(`telemetry sink 'wants' must be an array, got ${typeof wants}`)
58
65
  }
@@ -69,13 +76,24 @@ function checkSink(sink: unknown): TelemetrySink {
69
76
  // subscribed to the bus and heard nothing — the plugin was "on" and did
70
77
  // nothing. Refused here, which the caller turns into the same warning
71
78
  // a throwing hook gets: never a failed run.
72
- const s = sink as TelemetrySink
73
- if (typeof s.onRecord !== 'function' && typeof s.onRunSummary !== 'function') {
79
+ if (typeof onRecord !== 'function' && typeof onRunSummary !== 'function') {
74
80
  throw new Error(
75
81
  'telemetry sink handles nothing: neither onRecord nor onRunSummary is a function',
76
82
  )
77
83
  }
78
- return s
84
+ for (const [key, fn] of Object.entries({ onRecord, onRunSummary, flush })) {
85
+ if (fn !== undefined && typeof fn !== 'function') {
86
+ throw new Error(`telemetry sink '${key}' must be a function, got ${typeof fn}`)
87
+ }
88
+ }
89
+ // Bound: a sink written as a class reads its own state through `this`.
90
+ return {
91
+ ...(typeof name === 'string' ? { name } : {}),
92
+ ...(wants !== undefined ? { wants: [...wants] } : {}),
93
+ ...(onRecord !== undefined ? { onRecord: onRecord.bind(sink) } : {}),
94
+ ...(onRunSummary !== undefined ? { onRunSummary: onRunSummary.bind(sink) } : {}),
95
+ ...(flush !== undefined ? { flush: flush.bind(sink) } : {}),
96
+ }
79
97
  }
80
98
 
81
99
  /**
@@ -147,6 +165,7 @@ export async function subscribeTelemetry(
147
165
  return {
148
166
  emitSummary: (summary) => source.emitSummary(summary),
149
167
  flush: (step) => source.flush(step),
168
+ endServers: (final, keep) => source.endServers(final, keep),
150
169
  dispose,
151
170
  ...(source.track !== undefined ? { track: source.track } : {}),
152
171
  }
@@ -10,7 +10,9 @@
10
10
  // rendering-oriented `WireEvent` stream.
11
11
  //
12
12
  // A sink is observe-only BY CONSTRUCTION: its only input is a plain-data
13
- // record (one object every sink receives, not frozen); its `TelemetryContext` carries read-only metadata and NO mutable
13
+ // record (one object every sink receives, frozen once, deep: a sink that
14
+ // cleared a `task.end`'s `failedAttempts` cleared the outcome's own and
15
+ // crashed the run's failure log); its `TelemetryContext` carries read-only metadata and NO mutable
14
16
  // run handle (no bus, no Cache, no RunRequest). There is no API path from a
15
17
  // sink back into scheduling/caching/exec — telemetry provably cannot change
16
18
  // what or how tasks run. Contrast `cache`/`executor`, which return objects
@@ -34,7 +36,8 @@ import {
34
36
  teardownTimeoutMs,
35
37
  thrownMessage,
36
38
  } from '../util/index.js'
37
- import type { RunEvent, RunEventSubscriber } from '../report/index.js'
39
+ import { neverStarted, type RunEvent, type RunEventSubscriber } from '../report/index.js'
40
+ import { freezeDeep } from './read-only.js'
38
41
 
39
42
  /** Bumped when the record shape changes. Readers MUST check `v`. */
40
43
  export const TELEMETRY_SCHEMA_VERSION = 4
@@ -256,14 +259,20 @@ export interface RunSummaryRecord {
256
259
  startedAt: number
257
260
  endedAt: number
258
261
  totalDurationMs: number
262
+ /** Every task the run had, as the terminal's total: `tasks` plus `notRunCount`. */
259
263
  taskCount: number
260
264
  failedCount: number
261
265
  /**
262
- * Tasks a shutdown signal or an embedder's abort killed (v3). They are
263
- * not in `tasks`, which holds real runs only, so without this a stopped
264
- * run read as a failure with nothing failed (item 851).
266
+ * Tasks a shutdown signal or an embedder's abort killed once started (v3),
267
+ * each in `tasks` as `aborted`: without the count a stopped run read as a
268
+ * failure with nothing failed (item 851).
265
269
  */
266
270
  abortedCount: number
271
+ /**
272
+ * Tasks the stop reached before they started: the terminal's `not run`.
273
+ * Not in `tasks`, since they never ran. Absent when none. Additive.
274
+ */
275
+ notRunCount?: number
267
276
  hitCount: number
268
277
  /** Hits by the layer that answered, up-to-date ones included. */
269
278
  hitLocalCount: number
@@ -318,8 +327,9 @@ export interface RunStage {
318
327
  * computed: both a local `run()` and the distributed controller build their
319
328
  * `TaskTelemetry[]` (each via `deriveCacheSource`) and call this, so a
320
329
  * distributed run and a local run produce byte-identical summaries and land in
321
- * the same ingest. The per-task tallies (taskCount / failedCount /
322
- * hitLocal|Remote|Count) derive from `tasks`; `totalDurationMs` (wall time) and
330
+ * the same ingest. The per-task tallies (failedCount /
331
+ * hitLocal|Remote|Count) derive from `tasks`, and `taskCount` from `tasks`
332
+ * plus `notRunCount`; `totalDurationMs` (wall time) and
323
333
  * `exitOk` (the run's overall verdict — which counts skipped tasks beyond the
324
334
  * recorded task list) are run-level facts and are passed in.
325
335
  */
@@ -332,6 +342,7 @@ export function assembleRunSummary(
332
342
  totalDurationMs: number
333
343
  exitOk: boolean
334
344
  abortedCount: number
345
+ notRunCount?: number
335
346
  stages?: readonly RunStage[]
336
347
  uploads?: { count: number; bytes: number; ms: number; failed: number }
337
348
  machines?: readonly MachineReport[]
@@ -360,9 +371,14 @@ export function assembleRunSummary(
360
371
  startedAt: timing.startedAt,
361
372
  endedAt: timing.endedAt,
362
373
  totalDurationMs: timing.totalDurationMs,
363
- taskCount: tasks.length,
374
+ // The terminal's total: the tasks a stop reached first are not in
375
+ // `tasks`, yet a summary of 2 beside a terminal's 3 read as a lost task.
376
+ taskCount: tasks.length + (timing.notRunCount ?? 0),
364
377
  failedCount,
365
378
  abortedCount: timing.abortedCount,
379
+ ...(timing.notRunCount !== undefined && timing.notRunCount > 0
380
+ ? { notRunCount: timing.notRunCount }
381
+ : {}),
366
382
  hitCount: hitLocalCount + hitRemoteCount,
367
383
  hitLocalCount,
368
384
  hitRemoteCount,
@@ -383,7 +399,11 @@ export function assembleRunSummary(
383
399
  }
384
400
  }
385
401
 
386
- /** A telemetry consumer. Observe-only: receives records, holds no run handle. */
402
+ /**
403
+ * A telemetry consumer. Observe-only: receives records, frozen, holds no
404
+ * run handle. A write to a record throws, and the sink is switched off for
405
+ * the run as for any throw.
406
+ */
387
407
  export interface TelemetrySink {
388
408
  readonly name?: string
389
409
  /**
@@ -395,7 +415,11 @@ export interface TelemetrySink {
395
415
  readonly wants?: ReadonlyArray<TelemetryRecord['kind']>
396
416
  /** A streaming record. MUST return promptly — buffer; do NOT await I/O. */
397
417
  onRecord?(record: TelemetryRecord): void
398
- /** The per-run summary, at run:end. MUST return promptly. */
418
+ /**
419
+ * The per-run summary, once at run:end; a requested persistent task the
420
+ * foreground keeps sends it when that task exits, so it reads the run's
421
+ * final verdict. MUST return promptly.
422
+ */
399
423
  onRunSummary?(summary: RunSummaryRecord): void
400
424
  /**
401
425
  * Drain buffered data. Awaited at run:end under a deadline — a sink that
@@ -433,6 +457,12 @@ export interface TelemetrySource {
433
457
  * each as the step `<plugin>:telemetry` through `step`.
434
458
  */
435
459
  flush(step?: <T>(id: string, work: () => Promise<T>) => Promise<T>): Promise<void>
460
+ /**
461
+ * Send the held `task.end` of each persistent task not in `keep`, read
462
+ * from `final` (by id): a server's outcome can change after its ready,
463
+ * when it dies before the run stops it or ends a kept session.
464
+ */
465
+ endServers(final: readonly TaskOutcome[], keep?: ReadonlySet<string>): void
436
466
  /**
437
467
  * Sample this task's process tree every `SAMPLE_MS` until the returned
438
468
  * function is called, the root exits, or the run ends. Present only when
@@ -602,6 +632,9 @@ export function createTelemetrySource(args: {
602
632
  }
603
633
 
604
634
  function deliver(record: TelemetryRecord): void {
635
+ // Once, for every sink: a `task.end` holds the outcome's own arrays, and
636
+ // a sink that cleared `failedAttempts` crashed the run's failure log.
637
+ freezeDeep(record)
605
638
  for (const sink of sinks) {
606
639
  if (disabled.has(sink) || sink.onRecord === undefined) continue
607
640
  const kinds = sink.wants ?? DEFAULT_KINDS
@@ -667,6 +700,16 @@ export function createTelemetrySource(args: {
667
700
  case 'task:complete': {
668
701
  const { node, outcome } = event
669
702
  if (node.config.exec === undefined) return // group task
703
+ // The stop reached it first: no task.start, and the summary counts
704
+ // it in `notRunCount`, not `tasks`, so no task.end either.
705
+ if (neverStarted(outcome)) return
706
+ // A server's end waits for its final outcome (`endServers`): one
707
+ // task.end per task, and the one sent at ready read `success` over
708
+ // a crash after it.
709
+ if (node.config.exec.persistent !== undefined) {
710
+ heldEnds.set(node.id, { ts, outcome })
711
+ return
712
+ }
670
713
  deliver({
671
714
  v: TELEMETRY_SCHEMA_VERSION,
672
715
  kind: 'task.end',
@@ -687,10 +730,26 @@ export function createTelemetrySource(args: {
687
730
  }
688
731
  }
689
732
 
733
+ const heldEnds = new Map<string, { ts: number; outcome: TaskOutcome }>()
690
734
  return {
691
735
  subscriber,
692
736
  ...(wantsSample ? { track } : {}),
737
+ endServers(final, keep) {
738
+ for (const [id, held] of heldEnds) {
739
+ if (keep?.has(id) === true) continue
740
+ heldEnds.delete(id)
741
+ const outcome = final.find((o) => o.node.id === id) ?? held.outcome
742
+ deliver({
743
+ v: TELEMETRY_SCHEMA_VERSION,
744
+ kind: 'task.end',
745
+ runId,
746
+ ts: held.ts,
747
+ ...taskTelemetryOf(outcome),
748
+ })
749
+ }
750
+ },
693
751
  emitSummary(summary: RunSummaryRecord): void {
752
+ freezeDeep(summary)
694
753
  for (const sink of sinks) {
695
754
  if (disabled.has(sink) || sink.onRunSummary === undefined) continue
696
755
  try {
@@ -712,9 +771,12 @@ export function createTelemetrySource(args: {
712
771
  // accessed_at bumps and every later plugin's teardown lost with it.
713
772
  // Sinks race concurrently, so each still gets the whole budget.
714
773
  const deadline = new AbortController()
774
+ // The deadline's warning names the sinks still out, as a throw names its own.
775
+ const pending = new Set<TelemetrySink>()
715
776
  const all = Promise.all(
716
777
  sinks.map(async (sink) => {
717
778
  if (disabled.has(sink) || sink.flush === undefined) return
779
+ pending.add(sink)
718
780
  try {
719
781
  await step(`${args.owners?.get(sink) ?? label(sink)}:telemetry`, () =>
720
782
  sink.flush!(deadline.signal),
@@ -723,12 +785,17 @@ export function createTelemetrySource(args: {
723
785
  // A flush failure can never break the run — but it still costs
724
786
  // this sink its export, so say so rather than dropping it.
725
787
  warn?.(`[vx] telemetry sink '${label(sink)}' failed to flush: ${thrownMessage(err)}`)
788
+ } finally {
789
+ pending.delete(sink)
726
790
  }
727
791
  }),
728
792
  )
729
793
  if (!(await settleWithin(all, ms))) {
730
794
  deadline.abort(new Error(`telemetry flush deadline (${ms}ms)`))
731
- warn?.(`[vx] telemetry flush timed out after ${ms}ms; buffered records lost`)
795
+ const names = [...pending].map((s) => `'${label(s)}'`).join(', ')
796
+ warn?.(
797
+ `[vx] telemetry flush timed out after ${ms}ms (sink${pending.size === 1 ? '' : 's'} ${names}); buffered records lost`,
798
+ )
732
799
  // A sink told of the abort says what it lost as its request ends; the
733
800
  // run ended first and that line was lost (vx-otel, on CI). Briefly.
734
801
  await settleWithin(all, ABORT_GRACE_MS)
@@ -767,7 +834,8 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
767
834
  project: o.node.projectName,
768
835
  task: o.node.taskName,
769
836
  status: o.status,
770
- cacheSource: deriveCacheSource(o.status),
837
+ // A hit whose restore failed is in no cache column: neither a miss nor a hit.
838
+ cacheSource: o.restoreFailed === true ? 'none' : deriveCacheSource(o.status),
771
839
  exitCode: o.exitCode,
772
840
  durationMs: o.durationMs,
773
841
  }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  selectFoldedDeps,
3
+ type StandIns,
3
4
  type FoldCandidate,
4
5
  type TaskNode,
5
6
  type TaskOutcome,
@@ -72,10 +73,11 @@ export function filterUpstreamHashes(
72
73
  filter: readonly string[] | undefined,
73
74
  selfProjectName: string,
74
75
  selfTaskId: string,
76
+ standIns?: StandIns,
75
77
  ): Array<[upstreamTaskId: string, hash: string]> {
76
78
  const candidates: FoldCandidate[] = []
77
79
  for (const u of upstream) if (u.hash) candidates.push({ node: u.node, unit: u.hash })
78
- return selectFoldedDeps(candidates, filter, selfProjectName, selfTaskId).map((c) => [
80
+ return selectFoldedDeps(candidates, filter, selfProjectName, selfTaskId, standIns).map((c) => [
79
81
  c.node.id,
80
82
  c.unit,
81
83
  ])