src/orchestrator/download-policy.ts — --download modes + the deferral gate
Purpose
Section titled “Purpose”Decides, ONCE per task at plan time, whether a remotely-executed task’s
outputs come home. Two questions live here: what mode each task gets, and
which tasks are safe to defer at all. Both are answered before scheduling,
so --dry can show them and the scheduler never re-derives them.
See design/download-policy-cas-cache-2026-08.md.
Public surface
Section titled “Public surface”export type DownloadMode = 'eager' | 'deferred' | 'never'
export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, string> // ineligible id → whyexport function resolveDownloadModes(args: { nodes: Map<string, TaskNode> policy: 'all' | 'toplevel' | 'none' localPlaced: ReadonlySet<string> // placed on a local executor — they write in place remoteOnly: ReadonlySet<string> // `exec.remote: 'only'`}): { modeOf: Map<string, DownloadMode>; downgrades: Map<string, string> }resolveDownloadModes— per task, in this order:neverforexec.remote: 'only';eagerfor policyallor a locally-placed task;eagerfor a requested or surfaced task undertoplevel;eagerfor an ineligible producer, with the reason indowngrades(what--dryprints); elsedeferred. Groups get no mode. Underallthe eligibility gate is not even computed.deferralEligibility(nodes)→ ineligible ids mapped to the reason.
The gate
Section titled “The gate”A dependent’s key folds an upstream’s KEY, never its output content
(pure-input transitive hashing), so deferral cannot move a key that way.
The one real channel is a task whose cache.inputs can OBSERVE a
producer’s outputs on disk — then its key differs by whether the bytes
arrived. Ineligible producers run eager; a refusal would break a
working build, so the gate is a DOWNGRADE, never an error.
Four ways to be ineligible, each conservative in the direction that matters:
- a same-project reader whose input-glob static prefix can overlap one of
the producer’s output-glob prefixes (
src/**vsdist/**cannot, and the coarse “same project” rule the design first sketched would have left--download=nonewith nothing to defer); - any task declaring
cache.inputs.runtime/workspaceRuntime— a shell command’s reads cannot be bounded, and deferral SKIPS the output clean, so a stale prior build is exactly what it would sample; - any task declaring
cache.inputs.workspaceFiles(boundary-free); - the producer declaring
cache.outputs.workspaceFiles(root-anchored).
A leading wildcard yields prefix . and reaches everything; a cacheable
task with no declared files counts as reading its whole project, and so
does a task with no cache block that a cached task depends on (its key
folds every file in its project, and the cached task folds that key). The
prefixes are compared as PATHS, not as strings: staticPrefix normalizes
the spelling, because ./out/** and out/** name one tree to the input
resolver and two different prefixes to a raw comparison — which deferred
a producer its own reader could see, and moved that reader’s key with a
transfer flag (item 441).
Invariants
Section titled “Invariants”--downloadis a RunOption, never task config, and never folded into a key — it is transfer tuning and cannot change what a command produces.exec.remote: 'only'isneverin both directions;--downloadcannot override it.
tests/download-policy.test.ts (gate both directions incl. the
false-positive controls, mode resolution, the e2e lifecycle, and a
same-project reader whose key must not move with --download however
its glob is spelled).