@vzn/vx 0.0.18 → 0.0.21

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 (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
package/src/index.ts CHANGED
@@ -3,20 +3,33 @@
3
3
  // This is the stable cross-package contract — any plugin or integration
4
4
  // package imports everything it needs from here via the bare
5
5
  // `'@vzn/vx'` specifier (never a deep `src/...` path). The surface is pinned
6
- // by tests/package-boundaries.test.ts; a widening updates that snapshot
7
- // deliberately. See docs/design/core-cloud-split-2026-06.md §3.5.
6
+ // by tests/package-boundaries.unsafe.test.ts; a widening updates that snapshot
7
+ // deliberately. The boundary law: docs/architecture.md § Repository shape.
8
8
 
9
9
  export { VERSION } from './version.js'
10
10
 
11
11
  // Clean error type — user-input failures print a message without a stack.
12
- // `clampInt` and `parseDecimalInt` ride along for the same reason the status
13
- // predicates do: without them on the façade an integration package writes its
14
- // own, and both had already happened. A bounds helper whose floor is
15
- // load-bearing (a fractional SQL LIMIT is a datatype mismatch, not a smaller
16
- // page) should have one implementation — and so should the ONE strict integer
17
- // parser, whose entire purpose is that `Number()` silently accepts `0x10` and
18
- // `1e3` at a boundary where a typo must be an error, not a different number.
19
- export { clampInt, parseDecimalInt, parseSize, UserError, isUserError } from './util/index.js'
12
+ // `clampInt` rides along for the same reason the status predicates do:
13
+ // without it on the façade an integration package writes its own, and that
14
+ // had already happened. A bounds helper whose floor is load-bearing (a
15
+ // fractional SQL LIMIT is a datatype mismatch, not a smaller page) should
16
+ // have one implementation.
17
+ export { clampInt, UserError, isUserError } from './util/index.js'
18
+ // "Did you mean": the hint core's own verbs give for a near-miss name, for a
19
+ // plugin verb to give the same one.
20
+ export { nearMatches } from './util/index.js'
21
+ // Every hook a plugin may fill, in pipeline order — the one list the
22
+ // loader, the host and `vx info` read; exported so a doc pin outside core
23
+ // (the site's guides) holds its tables to the same list.
24
+ export { PLUGIN_HOOKS, type PluginHook } from './config.js'
25
+ // The workspace doctor's facts (`vx info`), for a host that answers the
26
+ // same question over another surface — `@vzn/vx-mcp`'s `getWorkspaceInfo`.
27
+ export { collectInfo, type CollectInfoOptions, type InfoFacts } from './orchestrator/index.js'
28
+ // The machine as this process may use it — cores and memory capped by the
29
+ // cgroup a container runs under. Core's default worker count reads the
30
+ // first; a policy that budgets memory (`@vzn/vx-schedule-history`) reads
31
+ // the second, so the cgroup walk has one implementation.
32
+ export { machineMemoryBytes, machineParallelism } from './util/index.js'
20
33
 
21
34
  // Schema types and helpers (used by user vx.config files and presets).
22
35
  export type {
@@ -28,36 +41,26 @@ export type {
28
41
  CacheConfig,
29
42
  CacheInputs,
30
43
  CacheOutputs,
31
- ResourcesConfig,
32
44
  SandboxConfig,
33
- SandboxNetworkConfig,
45
+ SandboxGrants,
46
+ SandboxDenials,
34
47
  } from './config.js'
35
48
  export { defineProject, defineWorkspace } from './config.js'
49
+ export { definePlugin } from './orchestrator/index.js'
36
50
 
37
- // Programmatic engine API (run / plan / prepare) + the graph primitives a
38
- // distribution submitter/agent reasons over + the cache-key hashing seam.
39
- // `deriveStableKeys` is THE shared stable-key derivation (remote-prefetch,
40
- // the local short-circuit, and the distributed submitter must never drift
41
- // on the stability gate); `captureGitContext`/`captureWorkspaceIdentity`
42
- // give agents + the submitter identity before/without a telemetry run.
51
+ // Programmatic engine API: run / plan / prepare (docs/cli.md § Programmatic
52
+ // API). The graph primitives, the cache-key hashing seam and the git / host
53
+ // context capture used to sit beside these for a distributed submitter that
54
+ // left the repo (2026-08); they went with it (2026-09-10) — a seam with no
55
+ // consumer is re-added when one appears, shaped by its use.
43
56
  export { run, planRun, prepareRun } from './orchestrator/index.js'
44
- // The per-task duration history a `schedule` plugin learns from (see
45
- // src/plugins/schedule-history — core's own plugins import core only via
46
- // this façade, which is what put these here).
47
- export { EmptyHistoryProvider, LocalHistoryProvider } from './orchestrator/index.js'
57
+ // The per-task duration history a `schedule` plugin learns from
58
+ // (`@vzn/vx-schedule-history` does; a plugin package reaches core only
59
+ // through this façade, which is what put these here).
60
+ export { LocalHistoryProvider } from './orchestrator/index.js'
48
61
  export type { HistoryProvider, HistoryTable, TaskHistory } from './orchestrator/index.js'
49
62
  export type { PreparedRun } from './orchestrator/index.js'
50
- export { computeTaskHash, createHashCache, deriveStableKeys } from './orchestrator/index.js'
51
- export type { DeriveStableKeysArgs, HashCache, StableKey } from './orchestrator/index.js'
52
- export {
53
- captureDefaultBranch,
54
- captureGitContext,
55
- captureHostContext,
56
- captureWorkspaceIdentity,
57
- detectCi,
58
- } from './orchestrator/index.js'
59
63
  export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './orchestrator/index.js'
60
- export { FULL_CACHE_POLICY, parseCachePolicy } from './orchestrator/index.js'
61
64
  export type {
62
65
  CachePolicy,
63
66
  Logger,
@@ -65,84 +68,95 @@ export type {
65
68
  RunOptions,
66
69
  RunSummary,
67
70
  } from './orchestrator/index.js'
68
- export { defaultLogger, resolveOutputView } from './orchestrator/index.js'
69
71
  // `splitTaskId` is on the façade because the alternative is what happened:
70
72
  // with only `taskId()` to JOIN an id and nothing exported to SPLIT one,
71
73
  // consumers roll their own `split('#', 2)` and drift from the graph, which
72
74
  // splits on the FIRST '#'.
73
- export {
74
- buildTaskGraph,
75
- expandRequested,
76
- isGroupTask,
77
- markSurfacedDeps,
78
- splitTaskId,
79
- } from './graph/index.js'
80
- export type {
81
- OutputFingerprint,
82
- TaskNode,
83
- TaskOutcome,
84
- TaskStatus,
85
- VerifyVerdict,
86
- } from './graph/index.js'
87
- // The one output-tree diff implementation (verify verdicts + a serve's
88
- // cross-machine fingerprint diff both name rels through it).
89
- export { diffOutputTrees } from './orchestrator/index.js'
75
+ export { splitTaskId } from './graph/index.js'
76
+ export type { TaskNode, TaskOutcome, TaskStatus } from './graph/index.js'
90
77
 
91
- // Cache classes + the layer interface (the `cache` capability's currency)
92
- // and input-output resolution. The blob-CAS/digest substrate
93
- // (cas-backend.ts / digest.ts) stays module-internal until it has a
94
- // consumer — no speculative public API. `cleanOutputs` is public for the
95
- // distributed submitter's targeted output materialization (wipe declared
96
- // outputs, then `restoreOutputs` the artifact — never a naive re-run).
97
- export {
98
- Cache,
99
- LayeredCache,
100
- GitFilesCache,
101
- cleanOutputs,
102
- resolveInputs,
103
- resolveOutputs,
104
- } from './cache/index.js'
78
+ // Cache classes + the layer interface (the `cache` capability's currency).
79
+ export { Cache, LayeredCache } from './cache/index.js'
105
80
  export type { CacheLayer, RemoteCacheLayer, RunRecord, InvocationRecord } from './cache/index.js'
106
81
 
107
82
  // Workspace discovery + the project/config catalog surface — an
108
- // out-of-process service/CLI needs these. `readLockfile` is THE one reader
109
- // of vx-lock.json (the format carries its own version sentinel; a second
110
- // parser in a sibling package would drift), and the loader chain
111
- // (`loadWorkspace` → `listProjectMetas` → `loadProjectConfig`) is the same
112
- // one `vx show` uses. Workspace's `listProjects` re-exports as
113
- // `listProjectMetas` (the bare name once belonged to a metrics query).
114
- export { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from './workspace/index.js'
115
- export { readLockfile, LOCKFILE_NAME } from './workspace/index.js'
116
- export type { Lockfile, LockfileEntry } from './workspace/index.js'
83
+ // out-of-process service/CLI needs these. `loadProjectConfig` is the RAW
84
+ // per-file load (`vx lock` freezes exactly that); the resolved view a run
85
+ // or `vx show` sees — plugin stages applied — is `loadResolvedProjects`
86
+ // below. Workspace's `listProjects` re-exports as `listProjectMetas` (the
87
+ // bare name once belonged to a metrics query).
88
+ export { findWorkspaceRoot } from './workspace/index.js'
117
89
  export {
118
90
  loadWorkspace,
119
91
  loadProjectConfig,
120
92
  listProjects as listProjectMetas,
121
93
  } from './workspace/index.js'
122
94
  export type { ProjectMeta } from './workspace/index.js'
95
+ // The package graph as a run sees it (workspace deps by manifest), for a tool
96
+ // that needs a project's transitive closure the way `vx run` computes it.
97
+ export { buildPackageGraph } from './workspace/index.js'
98
+ // The run path's RESOLVED view — plugin `config` and `project` stages
99
+ // applied, cached evaluations served — for a reader outside the CLI (the
100
+ // MCP server's `listTasks`, an embedder's task catalog). What `vx show`
101
+ // prints.
102
+ export { loadResolvedProjects } from './orchestrator/index.js'
103
+ export type { ProjectEntry } from './workspace/index.js'
104
+ // The migration seam: how a generated config is planned, rendered, guarded
105
+ // and written. `vx init` (package.json scripts) uses it in core;
106
+ // `@vzn/vx-migrate` (Turbo, Nx) and any other adoption tool use it from here.
107
+ export {
108
+ applyMigration,
109
+ PERSISTENT_TASK_NAMES,
110
+ PERSISTENT_TODO,
111
+ quoteTsLiteral,
112
+ } from './workspace/index.js'
113
+ export type {
114
+ ApplyMigrationArgs,
115
+ MigrationFormat,
116
+ GeneratedProject,
117
+ GeneratedTask,
118
+ MigrationPlan,
119
+ RawExpr,
120
+ } from './workspace/index.js'
123
121
 
124
122
  // Plugin API — the run-level extension points. Behavior capabilities
125
123
  // (executor / cache) change WHAT/HOW work runs; the observe-only `telemetry`
126
124
  // capability is the canonical data-export path and cannot change behavior.
127
125
  // A plugin is declared in vx.workspace.ts via defineWorkspace({ plugins }).
128
126
  // See docs/design/observability-architecture-2026-06.md.
127
+ // The claimant's shell for a lockfile plugin (`@vzn/vx-lockfile`):
128
+ // the claim, the per-project key, the memo and the `--affected` diff around
129
+ // a parser; `reachDigests` is the Merkle-over-components digest both use.
130
+ export {
131
+ lockfileClaim,
132
+ reachDigests,
133
+ type LockfileClaimHooks,
134
+ type LockfileClaimOptions,
135
+ type ReachGraph,
136
+ } from './orchestrator/index.js'
129
137
  export type {
130
138
  VxPlugin,
139
+ PluginHooks,
140
+ PluginOrigin,
131
141
  CacheContext,
132
142
  ExecutorContext,
133
143
  CommandContext,
144
+ FingerprintChange,
145
+ FingerprintClaim,
146
+ FingerprintContext,
134
147
  GraphHookContext,
135
148
  KeyHookContext,
136
149
  PluginCommand,
137
150
  ProjectHookContext,
138
151
  ScheduleHookContext,
152
+ AdmitContext,
139
153
  WorkspaceHookContext,
140
154
  PluginSetupContext,
141
155
  } from './orchestrator/index.js'
142
- // Process primitives — what an executor plugin builds on. `@vzn/vx/plugins/local-executor`
143
- // is the reference implementation and imports exactly these.
144
- export { runCommand, runSandboxed } from './exec/index.js'
145
156
  // The per-task execution contract a plugin's `executor` capability returns.
157
+ // (`runCommand` / `runSandboxed`, the local executor's own primitives, left
158
+ // the façade 2026-09-10: no executor plugin built on them — `@vzn/vx-reapi`
159
+ // speaks a wire — and a seam with no consumer is a special case in waiting.)
146
160
  export type {
147
161
  ExecuteRequest,
148
162
  ExecuteResult,
@@ -166,7 +180,6 @@ export type {
166
180
  // plugin that renders a run as a markdown table takes the same unvalidated
167
181
  // task names core does, and the cloud job summary shipped without the escape.
168
182
  export {
169
- assembleRunSummary,
170
183
  escapeMarkdownCell,
171
184
  TELEMETRY_SCHEMA_VERSION,
172
185
  deriveCacheSource,
@@ -192,29 +205,11 @@ export type {
192
205
  TelemetrySink,
193
206
  } from './orchestrator/index.js'
194
207
 
195
- // The serializable event projection.
196
- export { projectNode, projectOutcome } from './orchestrator/index.js'
197
- export type { RunResult } from './orchestrator/index.js'
198
-
199
- // Event bus + wire form — adapters (otel-bridge, custom subscribers) ride this.
200
- export { createEventBus, wireForwarder, toWireEvent } from './orchestrator/index.js'
201
- export type {
202
- EventBus,
203
- RunEvent,
204
- RunEventSubscriber,
205
- WireEvent,
206
- TaskView,
207
- OutcomeView,
208
- } from './orchestrator/index.js'
208
+ // The serializable run result and its per-task views (what `run()` returns).
209
+ export type { RunResult, TaskView, OutcomeView } from './orchestrator/index.js'
209
210
 
210
- // Run-history queries over cache.db — what `vx why` / `vx last` read. An
211
- // out-of-process surface (an MCP server, a dashboard plugin) reads the same.
212
- export {
213
- cacheKeyDiff,
214
- explainCacheKeyQuery,
215
- getInvocation,
216
- getRun,
217
- listInvocations,
218
- listRuns,
219
- whyDidThisRerunQuery,
220
- } from './orchestrator/index.js'
211
+ // The one run-history query a reader outside the CLI takes today: `vx why`'s
212
+ // answer, which `@vzn/vx-mcp` serves. The event bus / wire form and the
213
+ // other history readers (`listRuns`, `getRun`, …) left the façade 2026-09-10
214
+ // with no consumer; the telemetry seam above is the canonical export path.
215
+ export { whyDidThisRerunQuery } from './orchestrator/index.js'
@@ -0,0 +1,137 @@
1
+ // What stands between the scheduler's `execute` callback and executeTask:
2
+ // two rules that decide whether a ready task runs NOW and whether its
3
+ // result may be SAVED. Neither changes what the task is.
4
+ //
5
+ // - In-flight dedup: an embedder running concurrent delegated runs in one
6
+ // process (a daemon built on the façade; core ships none) supplies an
7
+ // `inflight` registry; a task whose key
8
+ // a sibling is already computing waits for it and cache-hits on what it
9
+ // saved. A stateless `vx run` passes none and takes the untouched path.
10
+ // - Continue-taint: under `continueMode: 'always'` a task runs although an
11
+ // upstream failed. Its key is the healthy one (pure-input hashing) but
12
+ // its bytes are not, so the taint is tracked here and propagates through
13
+ // every success built on it — or a grand-dependent would cache the same
14
+ // partial tree one hop later. Only that mode ever executes a task behind
15
+ // a failure; the other modes skip it, so a default run carries no check.
16
+ //
17
+ // Split from `run.ts` on 2026-09-10 (pure motion).
18
+
19
+ import type { CachePolicy } from '../cache/index.js'
20
+ import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
21
+ import { executeTask, type ExecuteArgs } from './execute-task.js'
22
+ import type { ShortCircuit } from './local-shortcircuit.js'
23
+ import { computeTaskHash, type ComputeHashArgs } from './task-hash.js'
24
+
25
+ /**
26
+ * Records which tasks ran behind a failure and answers, per task, whether
27
+ * this one does. Disabled (always `false`, nothing recorded) unless the
28
+ * run's `continueMode` is `'always'`.
29
+ */
30
+ export function taintTracker(
31
+ enabled: boolean,
32
+ ): (node: TaskNode, upstream: TaskOutcome[]) => boolean {
33
+ if (!enabled) return () => false
34
+ const tainted = new Set<string>()
35
+ return (node, upstream) => {
36
+ const taint = upstream.some(
37
+ // A restore-tier task may run before its deps and see holes here;
38
+ // it never saves anyway (a hit restores), so a hole is not taint.
39
+ (u) =>
40
+ u !== undefined &&
41
+ (u.status === 'failed' ||
42
+ u.status === 'aborted' ||
43
+ u.status === 'skipped' ||
44
+ tainted.has(u.node.id)),
45
+ )
46
+ if (taint) tainted.add(node.id)
47
+ return taint
48
+ }
49
+ }
50
+
51
+ export interface AdmissionArgs {
52
+ /** The shared registry, or undefined for a stateless run (no dedup). */
53
+ inflight: Map<string, Promise<void>> | undefined
54
+ policy: CachePolicy
55
+ shortCircuit: ShortCircuit
56
+ /** Everything `computeTaskHash` needs besides the node and its upstream. */
57
+ hashArgs: Omit<ComputeHashArgs, 'node' | 'upstream' | 'nestedProjectDirs'> & {
58
+ nestedDirsByProject: ReadonlyMap<string, string[]>
59
+ }
60
+ /**
61
+ * The run's execute-args builder. `reuseProbe: false` drops the up-front
62
+ * probe: a task joining a sibling must let executeTask probe fresh,
63
+ * because the probe predates the sibling's save.
64
+ */
65
+ buildExecuteArgs: (node: TaskNode, upstream: TaskOutcome[], reuseProbe?: boolean) => ExecuteArgs
66
+ }
67
+
68
+ /** The scheduler's `execute` callback: dedup when it can help, else executeTask. */
69
+ export function admitTasks(
70
+ args: AdmissionArgs,
71
+ ): (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome> {
72
+ const { inflight, policy, shortCircuit, hashArgs, buildExecuteArgs } = args
73
+ return async (node, upstream) => {
74
+ // Dedup only helps when the sibling will WRITE the artifact and this
75
+ // task can READ it back — i.e. both axes effectively on.
76
+ const canRead = policy.localRead || policy.remoteRead
77
+ const canWrite = policy.localWrite || policy.remoteWrite
78
+ const cacheable =
79
+ !isGroupTask(node) &&
80
+ node.config.exec?.persistent === undefined &&
81
+ node.config.cache !== undefined &&
82
+ canRead &&
83
+ canWrite
84
+ // A restore-tier task (confirmed local hit, may run before its deps)
85
+ // needs no dedup — it's a restore, not an executor, and its live
86
+ // `upstream` is incomplete, so the dedup hash recompute would be
87
+ // wrong. Route it straight to executeTask, which reuses the up-front
88
+ // probe.
89
+ const restorable = shortCircuit.restoreTier.has(node.id)
90
+ if (inflight === undefined || !cacheable || restorable) {
91
+ return executeTask(buildExecuteArgs(node, upstream))
92
+ }
93
+ const { nestedDirsByProject, ...rest } = hashArgs
94
+ const hash = await computeTaskHash({
95
+ ...rest,
96
+ node,
97
+ upstream,
98
+ nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
99
+ })
100
+ const existing = inflight.get(hash)
101
+ if (existing !== undefined) {
102
+ // Join a sibling already computing this exact task: wait, then
103
+ // executeTask cache-hits on the artifact it just saved.
104
+ await existing.catch(() => {})
105
+ return executeTask(buildExecuteArgs(node, upstream, false))
106
+ }
107
+ // Become the executor: register a barrier siblings await. get→set has
108
+ // no await between, so registration is atomic — at most one executor
109
+ // per hash. Released on every exit (success / failure / throw).
110
+ let release!: () => void
111
+ inflight.set(
112
+ hash,
113
+ new Promise<void>((resolve) => {
114
+ release = resolve
115
+ }),
116
+ )
117
+ const execArgs = buildExecuteArgs(node, upstream)
118
+ try {
119
+ // `return await`, deliberately: the finally must run after the task
120
+ // settles, not when its promise is handed back.
121
+ return await executeTask(execArgs)
122
+ } finally {
123
+ // The barrier lifts when the ENTRY is there, not when the task is:
124
+ // the save runs off the slot (save-lane.ts), and a sibling released
125
+ // before it landed would probe a miss and run the task again. The
126
+ // executor's own return is not held — only the joiners wait.
127
+ const landed = execArgs.deferredSaves?.get(node.id)
128
+ const lift = (): void => {
129
+ execArgs.deferredSaves?.delete(node.id)
130
+ inflight.delete(hash)
131
+ release()
132
+ }
133
+ if (landed !== undefined) void landed.then(lift, lift)
134
+ else lift()
135
+ }
136
+ }
137
+ }
@@ -101,6 +101,9 @@ export class DeferredOutputs {
101
101
  private async run(taskId: string): Promise<void> {
102
102
  const entry = this.entries.get(taskId)!
103
103
  const producer = this.args.nodes.get(taskId)!
104
+ // Project outputs only: a task declaring `outputs.workspaceFiles` is
105
+ // never deferred (`deferralEligibility` forces it eager), so nothing
106
+ // workspace-anchored can reach this path.
104
107
  const outputs = producer.config.cache?.outputs.files ?? []
105
108
  const nestedProjectDirs = this.args.nestedDirsByProject.get(producer.projectName) ?? []
106
109
  const cleanArgs = { projectDir: producer.projectDir, outputs, nestedProjectDirs }