Skip to content

RFC: workspace task cache - #22

Merged
zkochan merged 6 commits into
mainfrom
rfc-workspace-task-cache
Aug 28, 2026
Merged

RFC: workspace task cache#22
zkochan merged 6 commits into
mainfrom
rfc-workspace-task-cache

Conversation

@zkochan

@zkochan zkochan commented Aug 26, 2026

Copy link
Copy Markdown
Member

Cache the output of a workspace project's own scripts — files, logs, and exit code — keyed on declared inputs, reusing the artifact protocol that #20 shipped.

Depends on #23 for the task graph. That dependency is load-bearing, not cosmetic — see below. Both #23's scheduler and the protocol change this RFC asked for have since shipped, so what is left here is the key, the declarations, and the restore.

Why pnpm rather than a tool on top

Two things pnpm knows that Turborepo and Nx cannot:

  • The resolved dependency graph, per project. Turborepo hashes the lockfile as a proxy, so any lockfile edit busts every project's cache. pnpm already computes and hashes each importer's actual resolved graph — calcDepStateInputKey, shipped with RFC: shared side-effects cache #20.
  • A file-granular content-addressed store. Turborepo stores each task's output as its own archive; a file in ten outputs is stored ten times.

Why it needs the task graph

An earlier revision of this RFC scoped itself as cache-only and keyed a task on the same-named upstream script. That is wrong, not merely limited.

Project A's test consumes node_modules/B/dist, produced by B's build. The same-name rule keys it on B's test — including tests A does not consume, omitting the build it does. Nothing makes up the difference: link targets enter the dependency-graph hash by identity alone (graph-hasher/src/index.ts:481-486 gives them fullPkgId: linkTargetNode, children: {}, no content).

The sharp case: B has build but no test script. Then A's test key contains nothing whatsoever about B. Change B's source, rebuild, and A's test hits cache against a dist it has never seen.

With #23's dependsOn, test: ['build'] names the producing task, and its pass-through rule keeps a project that lacks a script from severing the chain. The key becomes correct by construction rather than correct by accident.

What it specifies

  • The task key: identity, script text (including pre/post), declared input digests, declared env as a name-to-value map, the resolved package graph for external dependencies, and the declared upstream task keys for internal ones. Env values are hashed into the key and never recorded in provenance. The graph those upstream keys resolve through must be dependency-closed: a selection narrows which tasks are requested, never which projects the graph contains, or the key becomes a function of the invocation.
  • outputs, inputs, env, and cache alongside RFC: workspace task orchestration #23's dependsOn in the same tasks section. outputs is mandatory for cacheability, and an absent key means not cacheable rather than produces nothingRFC: shared side-effects cache #20's "absence of information is not permission" rule applied to a different field. outputs: [] is the positive assertion that lets lint be cached for its logs alone.
  • Restoring into a directory the user owns, which has no precedent in RFC: shared side-effects cache #20: never hardlink from CAFS (the first writer would corrupt the shared blob), delete what the previous run left, and refuse to overwrite a file nothing accounts for.
  • Two things easy to miss: a hit must replay captured logs, and a hit must still run syncInjectedDepsAfterScripts or consumers of an injected dependency see the previous build.
  • The remote tier is RFC: shared side-effects cache #20 with a different subject. Owner is always organization, compatibility is usually universal, and eligibility is trivially satisfied because a workspace task is first-party.

What has since shipped

The prerequisite this RFC called out — #20's rule that the protocol must not embed package identity in the shape of a request or response key, which ArtifactCandidate and ArtifactPayload violated by requiring package and sourceIntegrity next to the opaque key — landed in pnpm/pnpm#14267 while it was still free. Both types now carry a kind-discriminated subject, and a workspace-task subject is nameable on the wire today.

#20's two then-unfinished items also shipped, and they land here unevenly: persistent quarantine (pnpm/pnpm#14259) is inherited whole and matters more for tasks than for dependencies, while lockfile pinning (pnpm/pnpm#14243) does not carry over as it stands, because a pin is recorded on a package snapshot and a workspace task has none.

#23's scheduler is merged in both stacks. The one change it still needs for this RFC is the requested-versus-contained split described above.

Open for discussion

Whether this is pnpm's scope at all is the first question, and the RFC argues the "do nothing, let Turborepo do this" alternative as a real one — including that being a remote-cache backend for Turborepo is cheap, non-exclusive, and worth doing on its own merits. Also open: what an undeclared dependsOn should key on, whether pnpm can detect a task that reads an upstream output it never declared, non-relocatable output, input-hashing cost on misses, two tasks declaring the same output root, and where a task artifact's lockfile pin would live if it has one at all.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 51 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 33feeb85-3ced-44bb-9eab-189576000c02

📥 Commits

Reviewing files that changed from the base of the PR and between 2508faf and 92abdac.

📒 Files selected for processing (1)
  • text/0000-workspace-task-cache.md

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

zkochan added a commit to pnpm/pnpm that referenced this pull request Aug 27, 2026
Implements the pnpm-as-the-CI-engine RFC's engine as a Rust-only PoC
under the command name pipeline (the name ci is the clean-install
command): a frozen install first, affected-since-merge-base selection
as a pre-pass with full-graph fallthrough on workspace-root changes,
the pipeline's task set scheduled over the dependency-closed task
graph without bailing, a local task cache (pnpm/rfcs#22, local tier
only) with log replay and guarded working-tree restoration, and a run
report (events.ndjson + summary.json) under the pnpm cache directory.

New settings: pipelines and pipelineBase in pnpm-workspace.yaml, and
tasks entries gain outputs, inputs, env, and cache.

Task cache keys are computed for the whole plan before anything runs,
over a dependency-closed graph so a key never depends on how the run
was narrowed. The working-tree restore records content hashes of what
it produced and refuses to overwrite or delete anything it cannot
account for.

Deliberately single-stack on this PoC branch: no TypeScript
counterpart, per the experiment's scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
zkochan added a commit to pnpm/pnpm that referenced this pull request Aug 28, 2026
The shared-artifact protocol encoded package identity and source integrity at the top level of every candidate and signed payload. That shape could only represent dependency side effects, even though task caching is designed to reuse the same transport and trust model.

Move those fields into a discriminated subject shared by the Rust and TypeScript implementations. Dependency artifacts retain their package and source integrity, while workspace-task artifacts identify a workspace-relative project and task. Validate each subject against its artifact kind, input-key domain, and allowed owner type, and include the subject in pnpr's entry identity and candidate matching.

This intentionally changes the experimental wire format, so pnpr servers and clients must be upgraded together.

Related to pnpm/rfcs#22.
zkochan and others added 6 commits August 28, 2026 13:36
Cache the output of a workspace project's own scripts — files, logs, and
exit code — keyed on declared inputs, reusing the artifact protocol RFC
0007 shipped.

Scope it as a cache rather than a task runner: no dependsOn, upstream
tasks are the same-named script in each workspace dependency, which is
what runRecursive's topological chunking already implies. Adding
dependsOn later changes which upstream keys feed a task key and nothing
else in the document.

Two things pnpm knows that a tool sitting on top does not, and they are
the argument for building this at all: the resolved dependency graph per
project, so a task invalidates on the dependencies that project actually
resolves rather than on any lockfile edit; and a file-granular
content-addressed store, so identical outputs across tasks cost one copy.

Specify the key, the tasks section in pnpm-workspace.yaml with outputs
mandatory for cacheability, and the part with no precedent in 0007 —
restoring into a directory the user owns: never hardlink from CAFS,
delete what the previous run left, and refuse to overwrite a file
nothing accounts for. Note that a cache hit must still replay logs and
still sync injected dependencies.

Record the protocol change this needs first: ArtifactCandidate and
ArtifactPayload require package identity next to the opaque key, which
0007's own forward-compatibility constraint forbids. Both need a
kind-discriminated subject. pnpr is unaffected, and the change is free
only while the protocol is opt-in and alpha.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keying a task on the same-named upstream script is not merely limited,
it is wrong. Project A's test consumes node_modules/B/dist, produced by
B's build; the same-name rule keys it on B's test, which includes tests
A does not consume and omits the build it does. Link targets enter the
dependency-graph hash by identity alone, so nothing makes up the
difference. When B has build but no test script, A's test key contains
nothing about B at all, and a rebuild of B leaves A's test hitting cache
against a dist it has never seen.

Take a dependency on the workspace task orchestration RFC and let
dependsOn name the producing task, with its pass-through rule keeping a
project that lacks a script from severing the chain. Split the key's
dependency component in two: the resolved package graph for external
dependencies, the declared upstream task keys for internal ones.

Record hashing the materialized workspace dependencies as the fallback
for undeclared edges rather than the primary mechanism, and add the
question of how to detect a task that reads an upstream output it never
declared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Name the git-index fast path as the input-hashing question's expected
answer (index blob hashes via ls-files -s, hashing only stat-dirty
files), leaving the untracked-tree and stale-stat residue as what still
wants measuring. And require the last-outputs restoration record to
carry content hashes rather than a path set: a set cannot tell a
produced file from a produced-then-edited one, and the proof of concept
overwrote a user edit exactly that way before its record grew hashes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The subject change this RFC called a prerequisite has landed
(pnpm/pnpm#14267), as have RFC 0007's two then-unfinished items and the
orchestration RFC's scheduler, so the document was describing work as
pending that is now the ground it stands on. It also carried three
findings the pnpm-ci proof of concept established and one contradiction
with it:

- The graph a task's upstream keys resolve through must be
  dependency-closed. A selection that narrows the graph rather than the
  requested set truncates `^` edges at its boundary, which makes the key
  a function of the invocation: spurious misses when the selection
  widens, and a stale hit when a workspace dependency falls outside it
  entirely.
- The runtime version must be fingerprinted from the workspace, not the
  invoking process, or a `--dir` run splits the cache under a
  context-aware toolchain.
- The default input set is tracked *and* untracked-unignored files. A
  source file not yet added to the index is one somebody is editing, and
  the previous wording served a stale hit for it. The git-index fast path
  covers only the tracked half, which is the other half of the
  untracked-residue measurement.
- `cache: false` and `--no-cache` settle the bypass half of the bypass
  question; repair stays open.

Lockfile pinning does not carry over as it stands — a pin is recorded on
a package snapshot and a task has no snapshot — so that becomes an open
question rather than a lesser weight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3XFeFuiejqx4Fxp1WCd8e
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3XFeFuiejqx4Fxp1WCd8e
The orchestration RFC merged as 0008, so the cross-reference resolves and
a status note can say plainly what exists: the scheduler and the artifact
subject, plus a draft proof of concept whose findings this text already
carries. Nothing else here is built.

RFC 0007 has meanwhile recorded two things that reach this document: the
lockfile pin's actual shape, which sharpens why a task has nowhere to
record one, and the shipped single-channel reuse policy, under which a
persisted artifact is a miss once the configured server changes. Tasks
should keep that narrowing rather than relax it, but a whole task cache
going cold on a server rename is a different order of cost from rebuilding
one dependency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3XFeFuiejqx4Fxp1WCd8e
@zkochan
zkochan force-pushed the rfc-workspace-task-cache branch from 6621edc to 92abdac Compare August 28, 2026 11:37
@zkochan
zkochan marked this pull request as ready for review August 28, 2026 11:40
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

RFC: Define content-addressed caching for workspace tasks

📝 Documentation ✨ Enhancement 🕐 40+ Minutes

Grey Divider

AI Description

• Specifies task cache keys from declared inputs, environment, dependencies, and upstream tasks.
• Defines safe output restoration, log replay, local storage, and remote artifact reuse.
• Extends workspace task declarations with cache eligibility and input/output metadata.
Diagram

graph TD
  Config["Task declarations"] --> Graph["Task graph"] --> Key["Key builder"] --> Lookup["Cache lookup"]
  Lookup -- "cache miss" --> Runner["Task runner"] --> Store["Artifact store"]
  Lookup -- "cache hit" --> Restore["Safe restore"] --> Outputs["Workspace outputs"]
  Store --> Restore
  Runner --> Outputs
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Delegate caching to Turborepo or Nx
  • ➕ Avoids expanding pnpm's product scope
  • ➕ Uses mature task-cache ecosystems and cloud services
  • ➖ Duplicates pnpm's dependency graph and file hashing
  • ➖ Invalidates broadly on lockfile changes
  • ➖ Cannot reuse pnpm's file-granular content-addressed store
2. Hash materialized workspace dependencies
  • ➕ Remains correct without declared upstream task edges
  • ➕ Captures actual consumed workspace package contents
  • ➖ Hashes large materialized trees on misses
  • ➖ Cannot isolate which upstream outputs a task consumes
  • ➖ Adds repeated filesystem work compared with upstream key lookup
3. Use a project-local cache
  • ➕ Simpler indexing and lifecycle management
  • ➕ Matches common task-runner cache layouts
  • ➖ Loses reuse across repository checkouts
  • ➖ Loses file-level deduplication across tasks and projects
  • ➖ Introduces a separate cache requiring garbage collection

Recommendation: Proceed with the RFC's task-graph-based key and shared content-addressed store, shipping the local tier before remote reuse. This approach uniquely uses pnpm's resolved dependency graph and storage model while avoiding stale hits from same-named-task assumptions; implementation should resolve the undeclared-edge fallback and overlapping-output-root questions before enabling caching by default.

Files changed (1) +207 / -0

Documentation (1) +207 / -0
0000-workspace-task-cache.mdSpecify workspace task caching semantics and architecture +207/-0

Specify workspace task caching semantics and architecture

• Adds an RFC for caching successful workspace task outputs, logs, and exit codes using keys derived from declared inputs and a dependency-closed task graph. It defines task configuration, local and remote artifact reuse, safe working-tree restoration, implementation sequencing, alternatives, and unresolved correctness and operational questions.

text/0000-workspace-task-cache.md

@zkochan
zkochan merged commit 1cccf5e into main Aug 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant