@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
@@ -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
  )
@@ -9,7 +9,6 @@
9
9
  // 2026-09-10 as pure code motion, the mirror of miss-save.ts.
10
10
 
11
11
  import path from 'node:path'
12
- import type { CacheConfig } from '../config.js'
13
12
  import {
14
13
  type CacheEntry,
15
14
  cleanOutputPaths,
@@ -25,6 +24,7 @@ import type { TaskOutcome } from '../graph/index.js'
25
24
  import {
26
25
  asTrees,
27
26
  relPosix,
27
+ maskStreams,
28
28
  secretMask,
29
29
  span,
30
30
  splitNegations,
@@ -33,7 +33,7 @@ import {
33
33
  } from '../util/index.js'
34
34
  import type { ExecuteArgs } from './execute-task.js'
35
35
  import { entryHolds } from './miss-save.js'
36
- import { decodeOutputLog, maskStreams } from './output-log.js'
36
+ import { decodeOutputLog } from './output-log.js'
37
37
 
38
38
  export interface RestoreHitArgs {
39
39
  args: ExecuteArgs
@@ -58,9 +58,10 @@ export interface RestoreHitArgs {
58
58
  export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
59
59
  const { args, hash } = given
60
60
  const { node } = args
61
- const cacheCfg: CacheConfig | undefined = node.config.cache
62
- const outputs = cacheCfg?.outputs.files ?? []
63
- const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
61
+ const outputNested = args.outputNestedDirs ?? args.nestedProjectDirs
62
+ const cfgOut = node.config.outputs
63
+ const outputs = cfgOut?.files ?? []
64
+ const wsOutputs = cfgOut?.workspaceFiles ?? []
64
65
  const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
65
66
  // A layer that handed no rows: one SELECT, read by both paths below.
66
67
  const restore =
@@ -95,6 +96,11 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
95
96
  ? node.outputsAddedToBy.flatMap((g) => asTrees([g])).map((g) => taskGlob(g))
96
97
  : []
97
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
+ }
98
104
 
99
105
  // "Tree is already current" short-circuit — skip cleanOutputs
100
106
  // + restoreOutputs when the on-disk state matches what this
@@ -131,14 +137,22 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
131
137
  .filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
132
138
  .map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
133
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.
134
142
  const projTree = await resolveOutputTree({
135
143
  projectDir: node.projectDir,
136
144
  outputs,
137
- nestedProjectDirs: args.nestedProjectDirs,
145
+ nestedProjectDirs: outputNested,
146
+ inputs: ownInputs,
147
+ special: 'later',
148
+ clearing: true,
138
149
  })
139
150
  const wsTree = await resolveWorkspaceOutputTree({
140
151
  workspaceRoot: args.workspaceRoot,
141
152
  outputs: wsOutputs,
153
+ inputs: ownInputs,
154
+ special: 'later',
155
+ clearing: true,
142
156
  })
143
157
  endGlob()
144
158
  const actualAbs = projTree.files
@@ -209,10 +223,7 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
209
223
  // Held to the rows like the run-end snapshot (item 1087): a stray
210
224
  // written since the walk listed its directory, more than a racy
211
225
  // window before this, is not the entry's.
212
- const holds = entryHolds(
213
- node,
214
- outputWalkTakes(node.projectDir, outputs, args.nestedProjectDirs),
215
- )
226
+ const holds = entryHolds(node, outputWalkTakes(node.projectDir, outputs, outputNested))
216
227
  await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes, (files) =>
217
228
  holds(files, expected),
218
229
  )
@@ -232,11 +243,17 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
232
243
  const actual = await resolveOutputTree({
233
244
  projectDir: node.projectDir,
234
245
  outputs,
235
- nestedProjectDirs: args.nestedProjectDirs,
246
+ nestedProjectDirs: outputNested,
247
+ inputs: ownInputs,
248
+ special: 'later',
249
+ clearing: true,
236
250
  })
237
251
  const actualWs = await resolveWorkspaceOutputTree({
238
252
  workspaceRoot: args.workspaceRoot,
239
253
  outputs: wsOutputs,
254
+ inputs: ownInputs,
255
+ special: 'later',
256
+ clearing: true,
240
257
  })
241
258
  endGlob()
242
259
  skipRestore =
@@ -249,12 +266,14 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
249
266
  const cleanArgs = {
250
267
  projectDir: node.projectDir,
251
268
  outputs,
252
- nestedProjectDirs: args.nestedProjectDirs,
269
+ nestedProjectDirs: outputNested,
270
+ inputs: ownInputs,
253
271
  committed: committedFiles(args.gitFilesCache, node.projectDir),
254
272
  }
255
273
  const wsCleanArgs = {
256
274
  workspaceRoot: args.workspaceRoot,
257
275
  outputs: wsOutputs,
276
+ inputs: ownInputs,
258
277
  committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
259
278
  }
260
279
  const heldRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
@@ -269,83 +288,105 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
269
288
  // to run first: a reader polling a 64 MB output while ten hits restored
270
289
  // met ENOENT on 153,336 of 204,411 reads (2026-10-16). An ADDITIVE task
271
290
  // owns only its rows (item 588), and the restore rewrites exactly those.
291
+ const walkArgs = { special: 'later', clearing: true } as const
272
292
  let foundRels: string[] = []
273
293
  let foundWsRels: string[] = []
274
294
  let staleRels: string[] = []
275
295
  let staleWsRels: string[] = []
276
296
  let staleDirs: string[] = []
277
297
  let staleWsDirs: string[] = []
278
- if (!additive) {
279
- sweepRestoreTemps(node.projectDir, outputs, cleanArgs.committed)
280
- sweepRestoreTemps(args.workspaceRoot, wsOutputs, wsCleanArgs.committed)
281
- if (outputs.length > 0) {
282
- const tree = walked?.proj ?? (await resolveOutputTree(cleanArgs))
283
- foundRels = tree.files.map((f) => relPosix(node.projectDir, f))
284
- const blocking = inTheWay(foundRels, heldRels)
285
- staleRels = notHeld(foundRels, heldRels).filter((f) => !blocking.has(f))
286
- const dirs = splitDirs(node.projectDir, tree.emptyDirs, heldRels)
287
- staleDirs = dirs.stale
288
- if (blocking.size > 0 || dirs.blocking.length > 0) {
289
- await cleanOutputPaths({
290
- projectDir: node.projectDir,
291
- rels: [...blocking],
292
- outputs,
293
- committed: cleanArgs.committed,
294
- emptyDirs: dirs.blocking,
295
- })
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
+ }
296
332
  }
297
- }
298
- if (wsOutputs.length > 0) {
299
- const tree = walked?.ws ?? (await resolveWorkspaceOutputTree(wsCleanArgs))
300
- foundWsRels = tree.files.map((f) => relPosix(args.workspaceRoot, f))
301
- const blocking = inTheWay(foundWsRels, heldWsRels)
302
- staleWsRels = notHeld(foundWsRels, heldWsRels).filter((f) => !blocking.has(f))
303
- const dirs = splitDirs(args.workspaceRoot, tree.emptyDirs, heldWsRels)
304
- staleWsDirs = dirs.stale
305
- if (blocking.size > 0 || dirs.blocking.length > 0) {
306
- await cleanOutputPaths({
307
- projectDir: args.workspaceRoot,
308
- rels: [...blocking],
309
- outputs: wsOutputs,
310
- committed: wsCleanArgs.committed,
311
- emptyDirs: dirs.blocking,
312
- })
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
+ }
313
352
  }
314
353
  }
315
- }
316
- await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot, {
317
- files: outputs,
318
- workspaceFiles: wsOutputs,
319
- })
320
- if (staleRels.length > 0 || staleDirs.length > 0) {
321
- await cleanOutputPaths({
322
- projectDir: node.projectDir,
323
- rels: staleRels,
324
- emptyDirs: staleDirs,
325
- outputs,
326
- committed: cleanArgs.committed,
327
- // A stale file in an output directory the entry holds empty takes
328
- // only itself: the directory stays, as the miss left it.
329
- keep: new Set(
330
- heldRels
331
- .filter((r) => r.endsWith('/'))
332
- .map((r) => path.join(node.projectDir, r.slice(0, -1))),
333
- ),
334
- })
335
- }
336
- if (staleWsRels.length > 0 || staleWsDirs.length > 0) {
337
- await cleanOutputPaths({
338
- projectDir: args.workspaceRoot,
339
- rels: staleWsRels,
340
- emptyDirs: staleWsDirs,
341
- outputs: wsOutputs,
342
- committed: wsCleanArgs.committed,
343
- keep: new Set(
344
- heldWsRels
345
- .filter((r) => r.endsWith('/'))
346
- .map((r) => path.join(args.workspaceRoot, r.slice(0, -1))),
347
- ),
354
+ await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot, {
355
+ files: outputs,
356
+ workspaceFiles: wsOutputs,
348
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()
349
390
  }
350
391
  // The tree now holds this entry's bytes: stamp them for the next hit
351
392
  // (item 886).
@@ -363,7 +404,7 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
363
404
  hash,
364
405
  projectDir: node.projectDir,
365
406
  prefixes: dirPrefixes,
366
- holds: entryHolds(node, outputWalkTakes(node.projectDir, outputs, args.nestedProjectDirs)),
407
+ holds: entryHolds(node, outputWalkTakes(node.projectDir, outputs, outputNested)),
367
408
  })
368
409
  }
369
410
  // Restored outputs changed the project's tree — but on this
@@ -399,8 +440,8 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
399
440
  export function currentHit(restore: RestoreHitArgs): (() => TaskOutcome) | undefined {
400
441
  const { args, hash, hit } = restore
401
442
  const { node } = args
402
- const outputs = node.config.cache?.outputs.files ?? []
403
- if ((node.config.cache?.outputs.workspaceFiles?.length ?? 0) > 0) return undefined
443
+ const outputs = node.config.outputs?.files ?? []
444
+ if ((node.config.outputs?.workspaceFiles?.length ?? 0) > 0) return undefined
404
445
  if (outputs.length > 0) {
405
446
  const dirPrefixes = dirPrefixesOf(outputs)
406
447
  if (dirPrefixes === null || args.cache.outputDirsCurrent === undefined) return undefined
@@ -425,7 +466,9 @@ export function currentHit(restore: RestoreHitArgs): (() => TaskOutcome) | undef
425
466
  function hitOutcome(restore: RestoreHitArgs, restored: boolean): TaskOutcome {
426
467
  const { args, hash, hit, cacheOpStart, taskStartNs } = restore
427
468
  const { node, log } = args
428
- 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) {
429
472
  // An entry saved before masking, or by another machine, can hold a
430
473
  // secret-named variable's value (L-11). Asked only of a hit that prints.
431
474
  const secrets = secretMask(
@@ -433,7 +476,7 @@ function hitOutcome(restore: RestoreHitArgs, restored: boolean): TaskOutcome {
433
476
  node.config.exec?.env?.secret,
434
477
  )
435
478
  // The stored log holds both streams in the order the run printed them.
436
- for (const c of maskStreams(decodeOutputLog(hit.stdout), secrets ?? undefined)) {
479
+ for (const c of maskStreams(decodeOutputLog(stored), secrets ?? undefined)) {
437
480
  if (c.err) log.taskStderr(node, c.text)
438
481
  else log.taskStdout(node, c.text)
439
482
  }
@@ -447,7 +490,9 @@ function hitOutcome(restore: RestoreHitArgs, restored: boolean): TaskOutcome {
447
490
  // date is fresh here, and a remote hit waited on the network and
448
491
  // downloaded its entry, outputs or none (2026-10-24: "Up to date is
449
492
  // a fresh. It should not require any cache").
450
- 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 = {
451
496
  node,
452
497
  status,
453
498
  exitCode: hit.exitCode,
@@ -455,18 +500,21 @@ function hitOutcome(restore: RestoreHitArgs, restored: boolean): TaskOutcome {
455
500
  // `storedDurationMs`, which is what the hit skipped.
456
501
  durationMs: Math.round(performance.now() - cacheOpStart),
457
502
  storedDurationMs: hit.durationMs,
458
- ...(hit.cpuMs !== undefined ? { storedCpuMs: hit.cpuMs } : {}),
459
- ...(hit.peakRssBytes !== undefined ? { storedPeakRssBytes: hit.peakRssBytes } : {}),
460
- ...(hit.sizeBytes !== undefined ? { artifactBytes: hit.sizeBytes } : {}),
461
- ...(hit.outputRows !== undefined
462
- ? { outputBytes: hit.outputRows.reduce((n, r) => n + r.size, 0) }
463
- : {}),
464
- ...(hit.fetchMs !== undefined ? { fetchMs: hit.fetchMs } : {}),
465
503
  hash,
466
504
  restored: restored || hit.source === 'remote',
467
505
  wallclockStartNs: taskStartNs,
468
506
  wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
469
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
470
518
  }
471
519
 
472
520
  /**
@@ -522,7 +570,7 @@ function inTheWay(found: readonly string[], held: readonly string[]): Set<string
522
570
  }
523
571
 
524
572
  /** The found outputs the entry does not hold: the stale ones, once it has restored. */
525
- type Tree = { files: string[]; emptyDirs: string[] }
573
+ type Tree = { files: string[]; emptyDirs: string[]; findSpecial?: () => string[] }
526
574
 
527
575
  /**
528
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.