@vzn/vx 0.0.651 → 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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  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 +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. 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,12 +380,16 @@ 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.
295
- * `byBytes` (`inputStability.strategy: 'mutable'`) judges a write during
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.
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
298
395
  * (tsdown's package.json), not an edit undone, unless another command that
@@ -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,27 +442,40 @@ 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. */
354
469
  function inputsArgs(args: ComputeHashArgs): ResolveInputsArgs {
355
470
  const cfg = args.node.config
356
- const cacheCfg = cfg.cache
471
+ const cfgOut = cfg.outputs
357
472
  return {
358
473
  projectDir: args.node.projectDir,
359
474
  workspaceRoot: args.workspaceRoot,
360
475
  envSource: process.env,
361
476
  inputs: cfg.inputs,
362
- ownOutputs: cacheCfg?.outputs.files ?? [],
363
- ownWorkspaceOutputs: cacheCfg?.outputs.workspaceFiles ?? [],
477
+ ownOutputs: cfgOut?.files ?? [],
478
+ ownWorkspaceOutputs: cfgOut?.workspaceFiles ?? [],
364
479
  nestedProjectDirs: args.nestedProjectDirs,
365
480
  ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
366
481
  ...(args.probesAfterWrites?.has(args.node.id) === true ? { runtimeScope: args.node.id } : {}),
@@ -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
  }