@vzn/vx 0.0.646 → 0.0.649

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -0,0 +1,79 @@
1
+ // The critical-path score a `schedule` plugin feeds the scheduler, from
2
+ // whatever timings it learned. It imports only types, so the Learn page's
3
+ // scheduler simulator (through private/vx-bench/schedule-policy.ts) bundles
4
+ // it for the browser without the rest of `@vzn/vx`.
5
+
6
+ import type { TaskNode } from './task-graph.js'
7
+
8
+ /** Default duration when neither task history nor a workspace median exists. */
9
+ const DEFAULT_DURATION_MS = 1000
10
+
11
+ /**
12
+ * For each node, the expected remaining critical-path duration: its own p50
13
+ * plus the maximum over its dependents. A node with no history takes its
14
+ * assumed duration if one was given, else the workspace median; an empty
15
+ * history takes a flat default.
16
+ */
17
+ export function criticalPath(
18
+ nodes: readonly TaskNode[],
19
+ history: ReadonlyMap<string, { readonly p50DurationMs?: number | undefined }>,
20
+ assume: Readonly<Record<string, number>> = {},
21
+ ): ReadonlyMap<string, number> {
22
+ const p50s: number[] = []
23
+ for (const h of history.values()) {
24
+ if (h.p50DurationMs !== undefined) p50s.push(h.p50DurationMs)
25
+ }
26
+ p50s.sort((a, b) => a - b)
27
+ const workspaceMedian =
28
+ p50s.length > 0
29
+ ? (p50s[Math.floor(p50s.length / 2)] ?? DEFAULT_DURATION_MS)
30
+ : DEFAULT_DURATION_MS
31
+
32
+ const dependentsOf = new Map<string, string[]>()
33
+ for (const n of nodes) {
34
+ for (const upstreamId of n.deps) {
35
+ const list = dependentsOf.get(upstreamId)
36
+ if (list) list.push(n.id)
37
+ else dependentsOf.set(upstreamId, [n.id])
38
+ }
39
+ }
40
+ // A group runs nothing, so it has no history; the median would charge it
41
+ // a whole task and let a gate outrank the longest chain (PR #3286).
42
+ const ownDuration = (n: TaskNode): number =>
43
+ n.config.exec?.command === undefined
44
+ ? 0
45
+ : (history.get(n.id)?.p50DurationMs ?? assume[n.id] ?? workspaceMedian)
46
+
47
+ // Reverse-topological pass: a node's value is final once every dependent's
48
+ // is, so start from the sinks (no dependents) and release each upstream
49
+ // when its last dependent has been scored.
50
+ const memo = new Map<string, number>()
51
+ const nodeById = new Map<string, TaskNode>(nodes.map((n) => [n.id, n]))
52
+ const pending = new Map<string, number>()
53
+ const queue: TaskNode[] = []
54
+ for (const n of nodes) {
55
+ const count = dependentsOf.get(n.id)?.length ?? 0
56
+ pending.set(n.id, count)
57
+ if (count === 0) queue.push(n)
58
+ }
59
+ let head = 0
60
+ while (head < queue.length) {
61
+ const n = queue[head++]!
62
+ let downstream = 0
63
+ for (const dep of dependentsOf.get(n.id) ?? []) {
64
+ const d = memo.get(dep) ?? 0
65
+ if (d > downstream) downstream = d
66
+ }
67
+ memo.set(n.id, ownDuration(n) + downstream)
68
+ for (const up of n.deps) {
69
+ const left = (pending.get(up) ?? 0) - 1
70
+ pending.set(up, left)
71
+ if (left === 0) {
72
+ const upNode = nodeById.get(up)
73
+ if (upNode) queue.push(upNode)
74
+ }
75
+ }
76
+ }
77
+ for (const n of nodes) if (!memo.has(n.id)) memo.set(n.id, ownDuration(n))
78
+ return memo
79
+ }
@@ -0,0 +1,19 @@
1
+ import type { TaskConfig } from '../config.js'
2
+
3
+ /**
4
+ * The `build` a project gets when neither its config nor a plugin declared
5
+ * one (2026-10-04): a group behind `^build`, keyed on every file of
6
+ * the project. A package consumed as source then carries its files to every
7
+ * dependent behind `^build`, in the key and in `--affected`, and the chain
8
+ * through it to deeper builds holds. A keyed group, as a config may
9
+ * declare one (config-schema.ts).
10
+ */
11
+ export const DEFAULT_BUILD: TaskConfig = Object.freeze({
12
+ dependsOn: Object.freeze(['^build']),
13
+ inputs: Object.freeze({ files: Object.freeze(['**']) }),
14
+ })
15
+
16
+ /** The default `build`, which a menu of the tasks a user declared leaves out. */
17
+ export function isDefaultBuild(task: TaskConfig | undefined): boolean {
18
+ return task === DEFAULT_BUILD
19
+ }
@@ -0,0 +1,38 @@
1
+ // What a task is, for its history: a task whose command changed under the
2
+ // same name is a new task, and its timings start over (2026-10-23: "task
3
+ // can disappear and appear as a completely new one with the same name").
4
+ // Only what decides what the command costs: the command and the values it
5
+ // is given (`env.define`). Inputs, outputs, cache, sandbox grants, timeout,
6
+ // description and dependencies change no cost, so editing them keeps the
7
+ // history.
8
+
9
+ import { xxh3hex } from '../util/index.js'
10
+ import type { TaskNode } from './task-graph.js'
11
+
12
+ const memo = new WeakMap<TaskNode, string | undefined>()
13
+
14
+ /** The task's definition hash; undefined for one with no command (a group). */
15
+ export function taskDefinition(node: TaskNode): string | undefined {
16
+ if (memo.has(node)) return memo.get(node)
17
+ const exec = node.config.exec
18
+ const def =
19
+ exec?.command === undefined
20
+ ? undefined
21
+ : xxh3hex(
22
+ `${node.id}\0${exec.command}\0${JSON.stringify(
23
+ Object.entries(exec.env?.define ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
24
+ )}`,
25
+ )
26
+ memo.set(node, def)
27
+ return def
28
+ }
29
+
30
+ /** Each task's definition (`taskDefinition`) by id, groups left out: what a history reader matches rows to. */
31
+ export function taskDefinitions(nodes: Iterable<TaskNode>): Map<string, string> {
32
+ const out = new Map<string, string>()
33
+ for (const n of nodes) {
34
+ const def = taskDefinition(n)
35
+ if (def !== undefined) out.set(n.id, def)
36
+ }
37
+ return out
38
+ }
@@ -2,14 +2,14 @@
2
2
  // alone: the hash path, the sandbox's keyed set and `vx lock`'s
3
3
  // input-overlap rule read this one selection, so they cannot drift.
4
4
 
5
- import { UserError } from '../util/index.js'
6
5
  import {
6
+ UserError,
7
7
  DependencySpecError,
8
8
  compileTaskPattern,
9
9
  isTaskPattern,
10
10
  parseDependencySpec,
11
11
  type DependencySpec,
12
- } from './dependency-spec.js'
12
+ } from '../util/index.js'
13
13
  import type { TaskNode } from './task-graph.js'
14
14
 
15
15
  /**
@@ -49,7 +49,6 @@ export function refuseFiltersThroughGroups(nodes: ReadonlyMap<string, TaskNode>)
49
49
  if (filter === undefined || filter.length === 0) continue
50
50
  const deps = keyedDeps(node).flatMap((id) => nodes.get(id) ?? [])
51
51
  const groups = deps.filter(isGroupTask)
52
- if (groups.length === 0) continue
53
52
  for (const raw of filter) {
54
53
  const spec = parseSpec(raw, node.id)
55
54
  if (spec.negated) continue
@@ -63,16 +62,50 @@ export function refuseFiltersThroughGroups(nodes: ReadonlyMap<string, TaskNode>)
63
62
  `${node.id}: inputs.tasks: '${raw}' matches ${member.id} only through the group ${group.id}, so it selects nothing; name '${group.taskName}' or depend on ${member.id} directly`,
64
63
  )
65
64
  }
65
+ if (namesNoDependency(spec, node.config.dependsOn ?? [])) {
66
+ const named = deps.map((d) => d.id).join(', ') || 'none'
67
+ throw new UserError(
68
+ `${node.id}: inputs.tasks: '${raw}' names none of its dependencies (${named}), so it selects nothing and the key would not move with them; fix the name, or use [] to decouple on purpose`,
69
+ )
70
+ }
66
71
  }
67
72
  }
68
73
  }
69
74
 
75
+ /**
76
+ * An exact filter that matched no dependency, where no `dependsOn` entry
77
+ * may resolve to nothing for it. The load pairs a `pkg#task` entry, or a
78
+ * `pkg#task` dependency, with any form whose task half matches, since it
79
+ * cannot tell this project's name from another's
80
+ * (`assertFilterNamesDeclaredDeps`), so `['nope#build']` over
81
+ * `dependsOn: ['build']`, `['gen']` over `['b#gen']` and `['^gen']` over
82
+ * this project's own `['a#gen']` each selected nothing and the key stopped
83
+ * moving with the dependency: a stale hit. A `^` entry or a pattern naming
84
+ * the task may match nothing in a preset's project, as a `^build` filter
85
+ * may, so it lets the entry through.
86
+ */
87
+ function namesNoDependency(spec: DependencySpec, dependsOn: readonly string[]): boolean {
88
+ if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps' || isTaskPattern(spec.task))
89
+ return false
90
+ if (spec.kind === 'cross' && isTaskPattern(spec.project)) return false
91
+ return !dependsOn.some((raw) => {
92
+ let d: DependencySpec
93
+ try {
94
+ d = parseDependencySpec(raw, 'dependsOn')
95
+ } catch {
96
+ return false
97
+ }
98
+ if (d.kind !== 'self' && d.kind !== 'deps' && d.kind !== 'cross') return false
99
+ return (d.kind === 'deps' || isTaskPattern(d.task)) && nameMatcher(d.task)(spec.task)
100
+ })
101
+ }
102
+
70
103
  function parseSpec(raw: string, selfTaskId: string): DependencySpec {
71
104
  try {
72
- return parseDependencySpec(raw)
105
+ return parseDependencySpec(raw, 'inputs.tasks')
73
106
  } catch (err) {
74
107
  if (err instanceof DependencySpecError) {
75
- throw new UserError(`${selfTaskId}: inputs.tasks: ${err.message}`)
108
+ throw new UserError(`${selfTaskId}: ${err.message}`)
76
109
  }
77
110
  throw err
78
111
  }
@@ -7,7 +7,7 @@ export {
7
7
  isTaskPattern,
8
8
  parseDependencySpec,
9
9
  type DependencySpec,
10
- } from './dependency-spec.js'
10
+ } from '../util/index.js'
11
11
  export {
12
12
  type FoldCandidate,
13
13
  foldedDeps,
@@ -16,7 +16,10 @@ export {
16
16
  refuseFiltersThroughGroups,
17
17
  selectFoldedDeps,
18
18
  } from './folds.js'
19
+ export { DEFAULT_BUILD, isDefaultBuild } from './default-build.js'
19
20
  export { computeReverseDepCount } from './priorities.js'
21
+ export { criticalPath } from './critical-path.js'
22
+ export { taskDefinition, taskDefinitions } from './definition.js'
20
23
  export {
21
24
  type ContinueMode,
22
25
  deadServerBehind,
@@ -1,6 +1,6 @@
1
1
  // The ready-queue ranking, apart from the scheduler that uses it. Nothing
2
2
  // here touches the platform: the Learn page's scheduler simulator
3
- // (packages/vx-docs, through packages/vx-bench/schedule-policy.ts) bundles
3
+ // (private/vx-docs, through private/vx-bench/schedule-policy.ts) bundles
4
4
  // these two functions for the browser, and an import of the scheduler would
5
5
  // bring `util/` and its load-time `Bun` reads with them.
6
6
 
@@ -10,7 +10,8 @@ import type { TaskNode } from './task-graph.js'
10
10
  * `computeReverseDepCount` for a run with a restore tier (item 754). A
11
11
  * restore-tier task is a confirmed hit that never waits on its deps, so it
12
12
  * blocks only the exec-tier tasks that depend on it: an exec-tier task's
13
- * count is taken over the exec tier alone, which is exact, and a restore's
13
+ * count is taken over the exec tier, an edge through a restore it holds
14
+ * back counted too, and a restore's
14
15
  * is the sum over its direct exec-tier dependents of one plus theirs, plus
15
16
  * the weight of each restore that depends on it, so a restore that feeds
16
17
  * pending work, directly or through other restores, still goes first. A warm run's exec tier
@@ -37,16 +38,22 @@ export function tieredReverseDepCount(
37
38
  for (const id of nodes.keys()) zeros.set(id, 0)
38
39
  return zeros
39
40
  }
41
+ // A restore releases its dependents only once its own deps have settled
42
+ // (scheduler.ts, item 963), so an exec task under a restore blocks what
43
+ // that restore feeds: e1 → r → e2 held e2 until e1 ran, and e1 ranked 0
44
+ // behind every idle task. The exec tier is counted with each restore dep
45
+ // replaced by the exec tasks it waits on, through restores.
46
+ const view = execThroughRestores(nodes, exec, restoreTier)
40
47
  // An exec tier with no edge inside it (a warm run's groups) counts 0
41
48
  // everywhere: no closure to build.
42
49
  let linked = false
43
- for (const node of exec.values()) {
44
- if (node.deps.some((d) => exec.has(d))) {
50
+ for (const node of view.values()) {
51
+ if (node.deps.some((d) => view.has(d))) {
45
52
  linked = true
46
53
  break
47
54
  }
48
55
  }
49
- const counts = linked ? computeReverseDepCount(exec) : new Map<string, number>()
56
+ const counts = linked ? computeReverseDepCount(view) : new Map<string, number>()
50
57
  if (!linked) for (const id of exec.keys()) counts.set(id, 0)
51
58
  let feeds = false
52
59
  for (const node of exec.values()) {
@@ -89,6 +96,52 @@ export function tieredReverseDepCount(
89
96
  return counts
90
97
  }
91
98
 
99
+ /**
100
+ * The exec tier with each restore dep replaced by the exec tasks that
101
+ * restore waits on (directly or through other restores). A node with no
102
+ * restore dep is itself; the restore tier is walked once, post-order.
103
+ */
104
+ function execThroughRestores(
105
+ nodes: Map<string, TaskNode>,
106
+ exec: Map<string, TaskNode>,
107
+ restoreTier: ReadonlySet<string>,
108
+ ): Map<string, TaskNode> {
109
+ const isRestore = (id: string): boolean => restoreTier.has(id) && nodes.has(id)
110
+ const below = new Map<string, readonly string[]>()
111
+ const belowOf = (root: string): readonly string[] => {
112
+ const stack: Array<[id: string, depsDone: boolean]> = [[root, false]]
113
+ while (stack.length > 0) {
114
+ const [id, depsDone] = stack.pop()!
115
+ if (below.has(id)) continue
116
+ const deps = nodes.get(id)!.deps
117
+ if (!depsDone) {
118
+ stack.push([id, true])
119
+ for (const d of deps) if (isRestore(d) && !below.has(d)) stack.push([d, false])
120
+ continue
121
+ }
122
+ const out = new Set<string>()
123
+ for (const d of deps) {
124
+ if (exec.has(d)) out.add(d)
125
+ else for (const e of below.get(d) ?? []) out.add(e)
126
+ }
127
+ below.set(id, [...out])
128
+ }
129
+ return below.get(root)!
130
+ }
131
+ let view: Map<string, TaskNode> | undefined
132
+ for (const node of exec.values()) {
133
+ if (!node.deps.some(isRestore)) continue
134
+ const deps = new Set<string>()
135
+ for (const d of node.deps) {
136
+ if (isRestore(d)) for (const e of belowOf(d)) deps.add(e)
137
+ else deps.add(d)
138
+ }
139
+ view ??= new Map(exec)
140
+ view.set(node.id, { ...node, deps: [...deps] })
141
+ }
142
+ return view ?? exec
143
+ }
144
+
92
145
  /**
93
146
  * Compute, for each task in the graph, how many OTHER tasks are
94
147
  * transitively blocked on it. Tasks with the highest count are the
@@ -98,7 +151,11 @@ export function tieredReverseDepCount(
98
151
  * dependents only (`calculateReverseDeps` in
99
152
  * `packages/nx/src/tasks-runner/utils.ts`, read 2026-09-24).
100
153
  */
101
- export function computeReverseDepCount(nodes: ReadonlyMap<string, TaskNode>): Map<string, number> {
154
+ export function computeReverseDepCount(
155
+ nodes: ReadonlyMap<string, TaskNode>,
156
+ /** Count only these dependents (the exec tier); every one when absent. */
157
+ counted?: ReadonlyMap<string, unknown>,
158
+ ): Map<string, number> {
102
159
  // Exact transitive-dependent COUNTS need closure SETS (diamonds
103
160
  // double-count under naive summing). Set-of-strings closures are
104
161
  // O(N²) entries and took 8.5s on a 1090-package, 100-layer repo;
@@ -145,6 +202,10 @@ export function computeReverseDepCount(nodes: ReadonlyMap<string, TaskNode>): Ma
145
202
  // half the words of indexing by insertion order on a layered graph.
146
203
  const pos = new Int32Array(n)
147
204
  for (let t = 0; t < tail; t++) pos[topo[t]!] = t
205
+ const mask = new Uint32Array(words)
206
+ if (counted === undefined) mask.fill(0xffffffff)
207
+ else
208
+ for (let t = 0; t < tail; t++) if (counted.has(ids[topo[t]!]!)) mask[t >>> 5]! |= 1 << (t & 31)
148
209
  const closure = new Uint32Array(n * words)
149
210
  const counts = new Map<string, number>()
150
211
  for (let t = tail - 1; t >= 0; t--) {
@@ -158,7 +219,7 @@ export function computeReverseDepCount(nodes: ReadonlyMap<string, TaskNode>): Ma
158
219
  }
159
220
  let count = 0
160
221
  for (let w = t >>> 5; w < words; w++) {
161
- let v = closure[base + w]!
222
+ let v = closure[base + w]! & mask[w]!
162
223
  v = v - ((v >>> 1) & 0x55555555)
163
224
  v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
164
225
  count += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24
@@ -240,11 +301,16 @@ export function mergePriorities(
240
301
  // Baseline reverse-deps counts are bounded by N; scale the override so
241
302
  // it sorts above the baseline for any node it covers, and add the
242
303
  // baseline as a tie-break for parity within the override set.
304
+ // The lowest weight maps to one SCALE, not to 0: a weight of 0 (a p50
305
+ // that rounds to 0 ms) or below otherwise tied or sank under the
306
+ // unscored nodes it is meant to outrank.
243
307
  const SCALE = 1 << 20
308
+ let lo = 0
309
+ for (const w of overrides.values()) if (w < lo) lo = w
244
310
  const out = new Map(baseline)
245
311
  for (const [id, w] of overrides) {
246
312
  const b = baseline.get(id) ?? 0
247
- out.set(id, w * SCALE + b)
313
+ out.set(id, (w - lo + 1) * SCALE + b)
248
314
  }
249
315
  return out
250
316
  }