@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
@@ -16,6 +16,8 @@ import {
16
16
  buildTaskGraph,
17
17
  computeReverseDepCount,
18
18
  declaredTask,
19
+ setStandIns,
20
+ standInsOf,
19
21
  type TaskNode,
20
22
  } from '../graph/index.js'
21
23
  import { xxh3hex } from '../util/index.js'
@@ -25,7 +27,7 @@ import { codeIdentity } from './run-keys.js'
25
27
  type BuildGraphOptions = Parameters<typeof buildTaskGraph>[0]
26
28
 
27
29
  /** Moves every remembered graph: bump it when what a token stands for changes. */
28
- const GRAPH_MEMO_VERSION = 'graph-memo-v1'
30
+ const GRAPH_MEMO_VERSION = 'graph-memo-v2'
29
31
 
30
32
  /** What names a run's configs: the lock they were read from and the projects discovered. */
31
33
  export interface GraphMemo {
@@ -38,7 +40,9 @@ export interface GraphMemo {
38
40
  * A node as the memo holds it: its project and task, requested, its deps
39
41
  * and the tasks it adds outputs to as positions in the list (ids in full
40
42
  * were 123 KB for 1,206 tasks, and parsing them back cost what the walk
41
- * saved), and the globs that add to its own outputs.
43
+ * saved), the globs that add to its own outputs, and its stand-ins
44
+ * (rebuilt without it, `inputs.tasks` dropped the edges past a default
45
+ * build on a package cycle on every warm run).
42
46
  */
43
47
  type Row = [
44
48
  project: string,
@@ -47,6 +51,7 @@ type Row = [
47
51
  deps: number[],
48
52
  adds?: number[] | null,
49
53
  added?: string[] | null,
54
+ stands?: Array<[edge: number, past: number[]]>,
50
55
  ]
51
56
 
52
57
  /**
@@ -97,11 +102,23 @@ function rowsOf(nodes: Map<string, TaskNode>): Row[] {
97
102
  const pos = (ids: readonly string[]): number[] => ids.map((d) => at.get(d)!)
98
103
  return [...nodes.values()].map((n) => {
99
104
  const row: Row = [n.projectName, n.taskName, n.requested ? 1 : 0, pos(n.deps)]
100
- if (n.addsToOutputsOf !== undefined || n.outputsAddedToBy !== undefined)
105
+ const standsFor = standInsOf(n)
106
+ if (
107
+ n.addsToOutputsOf !== undefined ||
108
+ n.outputsAddedToBy !== undefined ||
109
+ standsFor !== undefined
110
+ )
101
111
  row.push(
102
112
  n.addsToOutputsOf === undefined ? null : pos(n.addsToOutputsOf),
103
113
  n.outputsAddedToBy ?? null,
104
114
  )
115
+ if (standsFor !== undefined)
116
+ row.push(
117
+ Object.entries(standsFor).map(([edge, past]): [number, number[]] => [
118
+ at.get(edge)!,
119
+ pos(past),
120
+ ]),
121
+ )
105
122
  return row
106
123
  })
107
124
  }
@@ -134,11 +151,16 @@ function rebuilt(
134
151
  const ids = (at: readonly number[]): string[] => at.map((i) => list[i]!.id)
135
152
  const nodes = new Map<string, TaskNode>()
136
153
  for (let i = 0; i < rows.length; i++) {
137
- const [, , , deps, adds, added] = rows[i]!
154
+ const [, , , deps, adds, added, stands] = rows[i]!
138
155
  const node = list[i]!
139
156
  node.deps = ids(deps)
140
157
  if (adds != null) node.addsToOutputsOf = ids(adds)
141
158
  if (added != null) node.outputsAddedToBy = added
159
+ if (stands !== undefined) {
160
+ const standsFor: Record<string, string[]> = {}
161
+ for (const [edge, past] of stands) standsFor[list[edge]!.id] = ids(past)
162
+ setStandIns(node, standsFor)
163
+ }
142
164
  nodes.set(node.id, node)
143
165
  }
144
166
  return nodes
@@ -3,7 +3,13 @@
3
3
  // select over the graph the hooks leave.
4
4
 
5
5
  import path from 'node:path'
6
- import type { GraphRules, TaskNode } from '../graph/index.js'
6
+ import {
7
+ setStandIns,
8
+ standInsOf,
9
+ type GraphRules,
10
+ type StandIns,
11
+ type TaskNode,
12
+ } from '../graph/index.js'
7
13
  import { relPosix, UserError } from '../util/index.js'
8
14
  import { type ProjectMeta, validateProjectConfig } from '../workspace/index.js'
9
15
  import { applyGraphHooks } from './plugin-host.js'
@@ -31,9 +37,13 @@ export async function applyGraphStage(
31
37
  const shown = (dir: string): string => relPosix(args.workspaceRoot, dir) || '.'
32
38
  // The lock shares a task config two tasks declare alike: an edit in place
33
39
  // works on this node's own copy.
34
- if (plugins.some((p) => p.graph !== undefined)) {
35
- for (const n of nodes.values())
36
- if (!isDefaultBuild(n.config)) n.config = structuredClone(n.config)
40
+ if (!plugins.some((p) => p.graph !== undefined)) return
41
+ for (const n of nodes.values())
42
+ if (!isDefaultBuild(n.config)) n.config = structuredClone(n.config)
43
+ const standIns = new Map<string, StandIns>()
44
+ for (const n of nodes.values()) {
45
+ const s = standInsOf(n)
46
+ if (s !== undefined) standIns.set(n.id, s)
37
47
  }
38
48
  await applyGraphHooks(
39
49
  plugins,
@@ -68,4 +78,17 @@ export async function applyGraphStage(
68
78
  },
69
79
  args.rules,
70
80
  )
81
+ // A hook may rebuild a node, which drops what its edges stand in for, or
82
+ // remove an edge a record names: re-attach each by id, less what is gone.
83
+ for (const [id, before] of standIns) {
84
+ const n = nodes.get(id)
85
+ if (n === undefined) continue
86
+ const kept: Record<string, string[]> = {}
87
+ for (const [edge, past] of Object.entries(before)) {
88
+ if (!n.deps.includes(edge)) continue
89
+ const still = past.filter((p) => n.deps.includes(p))
90
+ if (still.length > 0) kept[edge] = still
91
+ }
92
+ setStandIns(n, kept)
93
+ }
71
94
  }
@@ -3,12 +3,19 @@
3
3
  // until the remote answers; when every dependant hit, the server never
4
4
  // starts (2026-10-22), as it never starts behind local hits.
5
5
 
6
- import type { TaskNode } from '../graph/index.js'
6
+ import { idleServers, type TaskNode } from '../graph/index.js'
7
7
  import type { CacheEntry, CacheLayer } from '../cache/index.js'
8
8
  import type { ShortCircuit } from './local-shortcircuit.js'
9
9
  import { getContext } from './remote-prefetch.js'
10
10
 
11
- /** The persistent tasks nobody asked for whose every dependant is a local hit or a key the remote may hold. */
11
+ /**
12
+ * The persistent tasks nobody asked for that would be idle if the remote
13
+ * held every key it may hold, each with the remote keys it waits on: those
14
+ * above it, through the groups and servers nobody asked for between, as
15
+ * `idleServers` walks local hits. Held only on its direct dependants, a
16
+ * server behind `env: { dependsOn: ['dev'] }` or under another held server
17
+ * booted on a remote-only warm run.
18
+ */
12
19
  export function serversToHold(
13
20
  nodes: ReadonlyMap<string, TaskNode>,
14
21
  restoreTier: ReadonlySet<string>,
@@ -23,15 +30,28 @@ export function serversToHold(
23
30
  if (list === undefined) dependents.set(d, [n.id])
24
31
  else list.push(n.id)
25
32
  }
26
- for (const n of nodes.values()) {
27
- if (n.config.exec?.persistent === undefined || n.requested || n.surfaced === true) continue
28
- const ds = dependents.get(n.id)
29
- if (
30
- ds !== undefined &&
31
- ds.every((d) => restoreTier.has(d) || remoteAsk.has(d)) &&
32
- ds.some((d) => remoteAsk.has(d))
33
- )
34
- out.set(n.id, ds)
33
+ const idleNow = idleServers(nodes, dependents, restoreTier)
34
+ const between = new Set<string>()
35
+ const hoped = idleServers(
36
+ nodes,
37
+ dependents,
38
+ new Set([...restoreTier, ...remoteAsk.keys()]),
39
+ between,
40
+ )
41
+ for (const id of hoped) {
42
+ if (idleNow.has(id)) continue
43
+ const remote = new Set<string>()
44
+ const seen = new Set<string>()
45
+ const stack = [id]
46
+ while (stack.length > 0) {
47
+ for (const d of dependents.get(stack.pop()!) ?? []) {
48
+ if (seen.has(d)) continue
49
+ seen.add(d)
50
+ if (between.has(d)) stack.push(d)
51
+ else if (remoteAsk.has(d) && !restoreTier.has(d)) remote.add(d)
52
+ }
53
+ }
54
+ if (remote.size > 0) out.set(id, [...remote])
35
55
  }
36
56
  return out
37
57
  }
@@ -63,7 +83,9 @@ export function askRemote(
63
83
  }
64
84
  const out = new Map<string, Promise<readonly string[]>>()
65
85
  for (const [server, ds] of held) {
66
- const remote = ds.filter((d) => shortCircuit.remoteAsk.has(d))
86
+ // A set: counted against a list naming one twice, the hits never
87
+ // reached its length and the server waited forever.
88
+ const remote = new Set(ds.filter((d) => shortCircuit.remoteAsk.has(d)))
67
89
  out.set(
68
90
  server,
69
91
  new Promise((resolve) => {
@@ -72,12 +94,12 @@ export function askRemote(
72
94
  void ask(d).then((hit) => {
73
95
  if (hit === null) return resolve([])
74
96
  hits.set(d, hit)
75
- if (hits.size < remote.length) return
97
+ if (hits.size < remote.size) return
76
98
  for (const [id, entry] of hits) {
77
99
  shortCircuit.preProbed.set(id, { hash: shortCircuit.remoteAsk.get(id)!, hit: entry })
78
100
  shortCircuit.restoreTier.add(id)
79
101
  }
80
- resolve(remote)
102
+ resolve([...remote])
81
103
  })
82
104
  }),
83
105
  )
@@ -24,6 +24,7 @@ import type { TaskOutcome } from '../graph/index.js'
24
24
  import {
25
25
  asTrees,
26
26
  relPosix,
27
+ maskStreams,
27
28
  secretMask,
28
29
  span,
29
30
  splitNegations,
@@ -32,7 +33,7 @@ import {
32
33
  } from '../util/index.js'
33
34
  import type { ExecuteArgs } from './execute-task.js'
34
35
  import { entryHolds } from './miss-save.js'
35
- import { decodeOutputLog, maskStreams } from './output-log.js'
36
+ import { decodeOutputLog } from './output-log.js'
36
37
 
37
38
  export interface RestoreHitArgs {
38
39
  args: ExecuteArgs
@@ -57,6 +58,7 @@ export interface RestoreHitArgs {
57
58
  export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
58
59
  const { args, hash } = given
59
60
  const { node } = args
61
+ const outputNested = args.outputNestedDirs ?? args.nestedProjectDirs
60
62
  const cfgOut = node.config.outputs
61
63
  const outputs = cfgOut?.files ?? []
62
64
  const wsOutputs = cfgOut?.workspaceFiles ?? []
@@ -94,6 +96,11 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
94
96
  ? node.outputsAddedToBy.flatMap((g) => asTrees([g])).map((g) => taskGlob(g))
95
97
  : []
96
98
  const isAddition = (rel: string): boolean => addedGlobs.some((g) => g.match(rel))
99
+ const ownInputs = {
100
+ projectDir: node.projectDir,
101
+ workspaceRoot: args.workspaceRoot,
102
+ declared: node.config.inputs,
103
+ }
97
104
 
98
105
  // "Tree is already current" short-circuit — skip cleanOutputs
99
106
  // + restoreOutputs when the on-disk state matches what this
@@ -130,14 +137,22 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
130
137
  .filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
131
138
  .map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
132
139
  const endGlob = span('output glob')
140
+ // The FIFOs, sockets and devices listed only if the tree is stale: a
141
+ // listing reads every directory again, ~17% of a walk.
133
142
  const projTree = await resolveOutputTree({
134
143
  projectDir: node.projectDir,
135
144
  outputs,
136
- nestedProjectDirs: args.nestedProjectDirs,
145
+ nestedProjectDirs: outputNested,
146
+ inputs: ownInputs,
147
+ special: 'later',
148
+ clearing: true,
137
149
  })
138
150
  const wsTree = await resolveWorkspaceOutputTree({
139
151
  workspaceRoot: args.workspaceRoot,
140
152
  outputs: wsOutputs,
153
+ inputs: ownInputs,
154
+ special: 'later',
155
+ clearing: true,
141
156
  })
142
157
  endGlob()
143
158
  const actualAbs = projTree.files
@@ -208,10 +223,7 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
208
223
  // Held to the rows like the run-end snapshot (item 1087): a stray
209
224
  // written since the walk listed its directory, more than a racy
210
225
  // window before this, is not the entry's.
211
- const holds = entryHolds(
212
- node,
213
- outputWalkTakes(node.projectDir, outputs, args.nestedProjectDirs),
214
- )
226
+ const holds = entryHolds(node, outputWalkTakes(node.projectDir, outputs, outputNested))
215
227
  await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes, (files) =>
216
228
  holds(files, expected),
217
229
  )
@@ -231,11 +243,17 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
231
243
  const actual = await resolveOutputTree({
232
244
  projectDir: node.projectDir,
233
245
  outputs,
234
- nestedProjectDirs: args.nestedProjectDirs,
246
+ nestedProjectDirs: outputNested,
247
+ inputs: ownInputs,
248
+ special: 'later',
249
+ clearing: true,
235
250
  })
236
251
  const actualWs = await resolveWorkspaceOutputTree({
237
252
  workspaceRoot: args.workspaceRoot,
238
253
  outputs: wsOutputs,
254
+ inputs: ownInputs,
255
+ special: 'later',
256
+ clearing: true,
239
257
  })
240
258
  endGlob()
241
259
  skipRestore =
@@ -248,12 +266,14 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
248
266
  const cleanArgs = {
249
267
  projectDir: node.projectDir,
250
268
  outputs,
251
- nestedProjectDirs: args.nestedProjectDirs,
269
+ nestedProjectDirs: outputNested,
270
+ inputs: ownInputs,
252
271
  committed: committedFiles(args.gitFilesCache, node.projectDir),
253
272
  }
254
273
  const wsCleanArgs = {
255
274
  workspaceRoot: args.workspaceRoot,
256
275
  outputs: wsOutputs,
276
+ inputs: ownInputs,
257
277
  committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
258
278
  }
259
279
  const heldRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
@@ -268,83 +288,105 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
268
288
  // to run first: a reader polling a 64 MB output while ten hits restored
269
289
  // met ENOENT on 153,336 of 204,411 reads (2026-10-16). An ADDITIVE task
270
290
  // owns only its rows (item 588), and the restore rewrites exactly those.
291
+ const walkArgs = { special: 'later', clearing: true } as const
271
292
  let foundRels: string[] = []
272
293
  let foundWsRels: string[] = []
273
294
  let staleRels: string[] = []
274
295
  let staleWsRels: string[] = []
275
296
  let staleDirs: string[] = []
276
297
  let staleWsDirs: string[] = []
277
- if (!additive) {
278
- sweepRestoreTemps(node.projectDir, outputs, cleanArgs.committed)
279
- sweepRestoreTemps(args.workspaceRoot, wsOutputs, wsCleanArgs.committed)
280
- if (outputs.length > 0) {
281
- const tree = walked?.proj ?? (await resolveOutputTree(cleanArgs))
282
- foundRels = tree.files.map((f) => relPosix(node.projectDir, f))
283
- const blocking = inTheWay(foundRels, heldRels)
284
- staleRels = notHeld(foundRels, heldRels).filter((f) => !blocking.has(f))
285
- const dirs = splitDirs(node.projectDir, tree.emptyDirs, heldRels)
286
- staleDirs = dirs.stale
287
- if (blocking.size > 0 || dirs.blocking.length > 0) {
288
- await cleanOutputPaths({
289
- projectDir: node.projectDir,
290
- rels: [...blocking],
291
- outputs,
292
- committed: cleanArgs.committed,
293
- emptyDirs: dirs.blocking,
294
- })
298
+ // The restore writes into the tree as a command would, and a write in a
299
+ // cycle no window covers is the user's to `vx watch` (`sawEvent`): with
300
+ // none, a hit's restore started one more cycle whenever the next
301
+ // command opened its window more than a racy window later (CI stalls).
302
+ const closeWindow =
303
+ args.commandWindows?.open(
304
+ node.id,
305
+ node.projectDir,
306
+ wsOutputs.length > 0 ? 'workspace' : 'project',
307
+ ) ?? (() => {})
308
+ try {
309
+ if (!additive) {
310
+ sweepRestoreTemps(node.projectDir, outputs, cleanArgs.committed)
311
+ sweepRestoreTemps(args.workspaceRoot, wsOutputs, wsCleanArgs.committed)
312
+ if (outputs.length > 0) {
313
+ const tree = walked?.proj ?? (await resolveOutputTree({ ...walkArgs, ...cleanArgs }))
314
+ // A FIFO, socket or device is found too: one the entry does not hold
315
+ // is a stray like any file, and one in a held path's way goes first.
316
+ foundRels = [...tree.files, ...(tree.findSpecial?.() ?? [])].map((f) =>
317
+ relPosix(node.projectDir, f),
318
+ )
319
+ const blocking = inTheWay(foundRels, heldRels)
320
+ staleRels = notHeld(foundRels, heldRels).filter((f) => !blocking.has(f))
321
+ const dirs = splitDirs(node.projectDir, tree.emptyDirs, heldRels)
322
+ staleDirs = dirs.stale
323
+ if (blocking.size > 0 || dirs.blocking.length > 0) {
324
+ await cleanOutputPaths({
325
+ projectDir: node.projectDir,
326
+ rels: [...blocking],
327
+ outputs,
328
+ committed: cleanArgs.committed,
329
+ emptyDirs: dirs.blocking,
330
+ })
331
+ }
295
332
  }
296
- }
297
- if (wsOutputs.length > 0) {
298
- const tree = walked?.ws ?? (await resolveWorkspaceOutputTree(wsCleanArgs))
299
- foundWsRels = tree.files.map((f) => relPosix(args.workspaceRoot, f))
300
- const blocking = inTheWay(foundWsRels, heldWsRels)
301
- staleWsRels = notHeld(foundWsRels, heldWsRels).filter((f) => !blocking.has(f))
302
- const dirs = splitDirs(args.workspaceRoot, tree.emptyDirs, heldWsRels)
303
- staleWsDirs = dirs.stale
304
- if (blocking.size > 0 || dirs.blocking.length > 0) {
305
- await cleanOutputPaths({
306
- projectDir: args.workspaceRoot,
307
- rels: [...blocking],
308
- outputs: wsOutputs,
309
- committed: wsCleanArgs.committed,
310
- emptyDirs: dirs.blocking,
311
- })
333
+ if (wsOutputs.length > 0) {
334
+ const tree =
335
+ walked?.ws ?? (await resolveWorkspaceOutputTree({ ...walkArgs, ...wsCleanArgs }))
336
+ foundWsRels = [...tree.files, ...(tree.findSpecial?.() ?? [])].map((f) =>
337
+ relPosix(args.workspaceRoot, f),
338
+ )
339
+ const blocking = inTheWay(foundWsRels, heldWsRels)
340
+ staleWsRels = notHeld(foundWsRels, heldWsRels).filter((f) => !blocking.has(f))
341
+ const dirs = splitDirs(args.workspaceRoot, tree.emptyDirs, heldWsRels)
342
+ staleWsDirs = dirs.stale
343
+ if (blocking.size > 0 || dirs.blocking.length > 0) {
344
+ await cleanOutputPaths({
345
+ projectDir: args.workspaceRoot,
346
+ rels: [...blocking],
347
+ outputs: wsOutputs,
348
+ committed: wsCleanArgs.committed,
349
+ emptyDirs: dirs.blocking,
350
+ })
351
+ }
312
352
  }
313
353
  }
314
- }
315
- await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot, {
316
- files: outputs,
317
- workspaceFiles: wsOutputs,
318
- })
319
- if (staleRels.length > 0 || staleDirs.length > 0) {
320
- await cleanOutputPaths({
321
- projectDir: node.projectDir,
322
- rels: staleRels,
323
- emptyDirs: staleDirs,
324
- outputs,
325
- committed: cleanArgs.committed,
326
- // A stale file in an output directory the entry holds empty takes
327
- // only itself: the directory stays, as the miss left it.
328
- keep: new Set(
329
- heldRels
330
- .filter((r) => r.endsWith('/'))
331
- .map((r) => path.join(node.projectDir, r.slice(0, -1))),
332
- ),
333
- })
334
- }
335
- if (staleWsRels.length > 0 || staleWsDirs.length > 0) {
336
- await cleanOutputPaths({
337
- projectDir: args.workspaceRoot,
338
- rels: staleWsRels,
339
- emptyDirs: staleWsDirs,
340
- outputs: wsOutputs,
341
- committed: wsCleanArgs.committed,
342
- keep: new Set(
343
- heldWsRels
344
- .filter((r) => r.endsWith('/'))
345
- .map((r) => path.join(args.workspaceRoot, r.slice(0, -1))),
346
- ),
354
+ await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot, {
355
+ files: outputs,
356
+ workspaceFiles: wsOutputs,
347
357
  })
358
+ if (staleRels.length > 0 || staleDirs.length > 0) {
359
+ await cleanOutputPaths({
360
+ projectDir: node.projectDir,
361
+ rels: staleRels,
362
+ emptyDirs: staleDirs,
363
+ outputs,
364
+ committed: cleanArgs.committed,
365
+ // A stale file in an output directory the entry holds empty takes
366
+ // only itself: the directory stays, as the miss left it.
367
+ keep: new Set(
368
+ heldRels
369
+ .filter((r) => r.endsWith('/'))
370
+ .map((r) => path.join(node.projectDir, r.slice(0, -1))),
371
+ ),
372
+ })
373
+ }
374
+ if (staleWsRels.length > 0 || staleWsDirs.length > 0) {
375
+ await cleanOutputPaths({
376
+ projectDir: args.workspaceRoot,
377
+ rels: staleWsRels,
378
+ emptyDirs: staleWsDirs,
379
+ outputs: wsOutputs,
380
+ committed: wsCleanArgs.committed,
381
+ keep: new Set(
382
+ heldWsRels
383
+ .filter((r) => r.endsWith('/'))
384
+ .map((r) => path.join(args.workspaceRoot, r.slice(0, -1))),
385
+ ),
386
+ })
387
+ }
388
+ } finally {
389
+ closeWindow()
348
390
  }
349
391
  // The tree now holds this entry's bytes: stamp them for the next hit
350
392
  // (item 886).
@@ -362,7 +404,7 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
362
404
  hash,
363
405
  projectDir: node.projectDir,
364
406
  prefixes: dirPrefixes,
365
- holds: entryHolds(node, outputWalkTakes(node.projectDir, outputs, args.nestedProjectDirs)),
407
+ holds: entryHolds(node, outputWalkTakes(node.projectDir, outputs, outputNested)),
366
408
  })
367
409
  }
368
410
  // Restored outputs changed the project's tree — but on this
@@ -424,7 +466,9 @@ export function currentHit(restore: RestoreHitArgs): (() => TaskOutcome) | undef
424
466
  function hitOutcome(restore: RestoreHitArgs, restored: boolean): TaskOutcome {
425
467
  const { args, hash, hit, cacheOpStart, taskStartNs } = restore
426
468
  const { node, log } = args
427
- if (hit.stdout) {
469
+ // Read here, once: a local entry reads its log from the index on access.
470
+ const stored = hit.stdout
471
+ if (stored) {
428
472
  // An entry saved before masking, or by another machine, can hold a
429
473
  // secret-named variable's value (L-11). Asked only of a hit that prints.
430
474
  const secrets = secretMask(
@@ -432,7 +476,7 @@ function hitOutcome(restore: RestoreHitArgs, restored: boolean): TaskOutcome {
432
476
  node.config.exec?.env?.secret,
433
477
  )
434
478
  // The stored log holds both streams in the order the run printed them.
435
- for (const c of maskStreams(decodeOutputLog(hit.stdout), secrets ?? undefined)) {
479
+ for (const c of maskStreams(decodeOutputLog(stored), secrets ?? undefined)) {
436
480
  if (c.err) log.taskStderr(node, c.text)
437
481
  else log.taskStdout(node, c.text)
438
482
  }
@@ -446,7 +490,9 @@ function hitOutcome(restore: RestoreHitArgs, restored: boolean): TaskOutcome {
446
490
  // date is fresh here, and a remote hit waited on the network and
447
491
  // downloaded its entry, outputs or none (2026-10-24: "Up to date is
448
492
  // a fresh. It should not require any cache").
449
- return {
493
+ // Assigned, not spread: one outcome per hit, and five conditional
494
+ // spreads were ~3 ms of an 8,002-hit warm run (2026-11-07).
495
+ const outcome: TaskOutcome = {
450
496
  node,
451
497
  status,
452
498
  exitCode: hit.exitCode,
@@ -454,18 +500,21 @@ function hitOutcome(restore: RestoreHitArgs, restored: boolean): TaskOutcome {
454
500
  // `storedDurationMs`, which is what the hit skipped.
455
501
  durationMs: Math.round(performance.now() - cacheOpStart),
456
502
  storedDurationMs: hit.durationMs,
457
- ...(hit.cpuMs !== undefined ? { storedCpuMs: hit.cpuMs } : {}),
458
- ...(hit.peakRssBytes !== undefined ? { storedPeakRssBytes: hit.peakRssBytes } : {}),
459
- ...(hit.sizeBytes !== undefined ? { artifactBytes: hit.sizeBytes } : {}),
460
- ...(hit.outputRows !== undefined
461
- ? { outputBytes: hit.outputRows.reduce((n, r) => n + r.size, 0) }
462
- : {}),
463
- ...(hit.fetchMs !== undefined ? { fetchMs: hit.fetchMs } : {}),
464
503
  hash,
465
504
  restored: restored || hit.source === 'remote',
466
505
  wallclockStartNs: taskStartNs,
467
506
  wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
468
507
  }
508
+ if (hit.cpuMs !== undefined) outcome.storedCpuMs = hit.cpuMs
509
+ if (hit.peakRssBytes !== undefined) outcome.storedPeakRssBytes = hit.peakRssBytes
510
+ if (hit.sizeBytes !== undefined) outcome.artifactBytes = hit.sizeBytes
511
+ if (hit.outputRows !== undefined) {
512
+ let bytes = 0
513
+ for (const r of hit.outputRows) bytes += r.size
514
+ outcome.outputBytes = bytes
515
+ }
516
+ if (hit.fetchMs !== undefined) outcome.fetchMs = hit.fetchMs
517
+ return outcome
469
518
  }
470
519
 
471
520
  /**
@@ -521,7 +570,7 @@ function inTheWay(found: readonly string[], held: readonly string[]): Set<string
521
570
  }
522
571
 
523
572
  /** The found outputs the entry does not hold: the stale ones, once it has restored. */
524
- type Tree = { files: string[]; emptyDirs: string[] }
573
+ type Tree = { files: string[]; emptyDirs: string[]; findSpecial?: () => string[] }
525
574
 
526
575
  /**
527
576
  * The empty directories found under the outputs (absolute) that the entry
@@ -1,12 +1,13 @@
1
1
  // Module contract for `orchestrator`. Cross-module imports must come
2
2
  // through here (enforced by tests/module-boundaries.test.ts).
3
3
 
4
- export { run, planRun, nxProjectTarget, projectNamed } from './run.js'
4
+ export { run, planRun, nxProjectTarget, projectAtDir, projectNamed } from './run.js'
5
5
  export { CYCLE_INTERRUPT } from './run-stop.js'
6
6
  export {
7
7
  namesRemote,
8
8
  openRemotes,
9
9
  prepareRun,
10
+ unlockedDir,
10
11
  workspaceCachePolicy,
11
12
  type PreparedRun,
12
13
  } from './prepare.js'
@@ -16,6 +17,7 @@ export type { AffectedReason } from './affected-tasks.js'
16
17
  export {
17
18
  discoverProjects,
18
19
  gitOfDiscovery,
20
+ ignoredConfigRefusal,
19
21
  keepDiscoveryGraph,
20
22
  loadProjects,
21
23
  loadResolvedProjects,
@@ -27,6 +29,7 @@ export {
27
29
  } from './projects.js'
28
30
  export { computeTaskHash, createHashCache, type HashCache } from './task-hash.js'
29
31
  export type { HeldPersistent, RunOptions, RunSummary } from './options.js'
32
+ export { commandWindows, type CommandWindows } from './command-windows.js'
30
33
  // Re-surface the cache policy contract (defined in the cache module) so
31
34
  // embedders constructing RunOptions.cache and the package façade can
32
35
  // reach it without importing the cache module directly.
@@ -91,3 +91,28 @@ export function readableDeps(
91
91
  return [...out.values()]
92
92
  }
93
93
  }
94
+
95
+ /**
96
+ * A run's lookup of the projects nested in a keyed one, by its directory.
97
+ * A sandboxed task granted a linked package is walled from them: that
98
+ * package's key stops at a nested project (sandbox-request.ts). Built on
99
+ * the first ask, from every node the walk can reach.
100
+ */
101
+ export function nestedDirsOf(
102
+ nodes: ReadonlyMap<string, TaskNode>,
103
+ keyOnly: ReadonlyMap<string, TaskNode>,
104
+ nestedDirsByProject: ReadonlyMap<string, readonly string[]>,
105
+ ): (projectDir: string) => readonly string[] {
106
+ let byDir: Map<string, readonly string[]> | undefined
107
+ return (dir) => {
108
+ if (byDir === undefined) {
109
+ byDir = new Map()
110
+ for (const map of [nodes, keyOnly]) {
111
+ for (const n of map.values()) {
112
+ byDir.set(n.projectDir, nestedDirsByProject.get(n.projectName) ?? [])
113
+ }
114
+ }
115
+ }
116
+ return byDir.get(dir) ?? []
117
+ }
118
+ }