Conversation
…h-with-dependson-outputs
Builds turbo binaries for 4 architectures (linux-amd64, linux-arm64, darwin-amd64, darwin-arm64) and publishes them as GitHub release assets with a sha256 checksums file. Triggered by tag push. No npm publish. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add config-only detection that identifies tasks whose inputs include files produced by their dependencies' outputs. This pattern causes unstable cache hashes because turbo computes file hashes before dependencies execute. Detection uses exact string matching, glob matching (via wax), and directory containment (bare "dir" input matches "dir/**" output), including through transitive dependency chains. Only same-package dependencies are checked since cross-package tasks write to different directories. Results are deduplicated by task name and shown as info messages in the run prelude, informing the config author regardless of --filter. 15 unit tests cover pattern matching (7), engine-level graph traversal (5), and config-level deduplication (3). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a task's inputs match outputs of its dependsOn tasks, defer file hashing to dispatch time -- after dependencies have executed and their output files exist on disk. This replaces the upfront hash computation that saw stale or missing files. Correctly handles: - Deterministic deps: second run is FULL TURBO - Non-deterministic deps (cache:false): dependent misses when output changes - Transitive chains: prepare -> transform -> build Implementation: - Tasks with dep-output overlaps are skipped in precompute_task_hashes() - At dispatch time (after deps executed), compute_deferred_hash() re-hashes the task's input files and computes the full task hash - TaskHasher.hashes uses RwLock to allow updating through &self 4 integration tests: simple caching, transitive caching, config info messages, and non-deterministic output detection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a non-deferred task depends on a deferred task, its precomputed hash includes the deferred task's stale dependency hash. At dispatch time, after the deferred task is re-hashed, propagate the rehash to all downstream tasks by tracking which tasks changed. Previously, deferred tasks were skipped entirely in precompute, causing "Missing hash for dependent task" errors when non-deferred downstream tasks tried to compute their dependency hashes. Fix: all tasks precompute normally (with potentially stale file state). At dispatch time, deferred tasks re-hash. Any task with a re-hashed dependency also re-hashes. This cascade is safe because the engine dispatches in dependency order. Adds integration test for the pattern: prepare -> schemas (deferred) -> checks (not deferred). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Config-level flagging identifies turbo.json patterns where a task's inputs match a dependency's outputs. At runtime, only defer hashing for tasks where the dependency package actually has the script in its package.json. If the dep has no script, the task is a no-op and won't produce output files -- upstream behavior is preserved. This eliminates unnecessary deferred rehashing for packages that inherit root turbo.json task definitions but don't implement the dependency task (e.g. eslint-config-y inheriting schemas/fetch config but having no fetch script). Also renames "detection" to "flagging" in comments and docs to distinguish config-level analysis from runtime behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tasks whose inputs match a dependency's outputs have unreliable precomputed hashes -- the real hash is only known after dependencies execute. Mark these tasks with <DEFERRED> in the TaskHashTracker so dry run output and summaries don't imply a stable cache prediction. The real hash is still used internally for cache operations via task_cache (correct because at dispatch time deps have executed). Uses the same deferred_hash_tasks set for both dry and non-dry modes -- no separate code paths. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The tracker override was applied unconditionally, causing build run summaries to show <DEFERRED> instead of the real hash. Now only applied in dry mode where deps haven't executed and the hash is based on stale file state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace generic <DEFERRED> with <DEPENDS_ON_OUTPUT: dep#task,...> listing which dependency tasks produce the outputs that this task's inputs match. This makes the dry run output actionable -- the user can see exactly which dependency relationship causes deferred hashing. Changes deferred_hash_tasks from HashSet to HashMap<TaskId, Vec<TaskId>> to carry the triggering dependency information through to display. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace all "deferred"/"defer" naming introduced by this branch with "depends-on-output" to clearly indicate the feature's purpose. The term "deferred" was already used in the repo for telemetry, SCM, and logging with generic "do later" semantics. Renames: deferred_hash_tasks -> depends_on_output_tasks, compute_deferred_hash -> compute_depends_on_output_hash, rehashed_tasks -> output_changed_tasks. Debug log entries now use "depends-on-output" prefix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7e12f70 to
6427425
Compare
48cec6d to
6427425
Compare
dtolnay/rust-toolchain installs targets for its configured toolchain, but rust-toolchain.toml overrides to a different nightly. Add explicit rustup target add to ensure the cross target is available for the active toolchain. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Research by Opus 5: PR #1 — Include outputs from dependsOn tasks in same turbo run input hashesRecommendation: close as resolved upstream. Every mechanism this PR builds now exists in What upstream shippedThe issue this PR was chasing — vercel/turborepo#8051, "task must run twice before it caches" — is closed, resolved by a series of PRs across v2.9.17–v2.10.6:
Upstream's visitor now has a first-class Notably, vercel#13273 deliberately removed locking from Migrating our fixturesThe behavior is opt-in per task via structured
{
"tasks": {
"prepare": { "inputs": ["package.json"], "outputs": ["generated.txt"] },
"build": {
"dependsOn": ["prepare"],
"inputs": [
{ "mode": "dependencyOutputs", "globs": ["generated.txt"], "from": ["prepare"] }
]
}
}
}
{
"tasks": {
"prepare": { "inputs": ["package.json"], "outputs": ["generated.txt"] },
"transform": {
"dependsOn": ["prepare"],
"outputs": ["transformed.txt"],
"inputs": [
{ "mode": "dependencyOutputs", "globs": ["generated.txt"], "from": ["prepare"] }
]
},
"build": {
"dependsOn": ["transform"],
"inputs": [
{ "mode": "dependencyOutputs", "globs": ["transformed.txt"], "from": ["transform"] }
]
}
}
}
{
"tasks": {
"prepare": { "inputs": ["package.json"], "outputs": ["generated.txt"] },
"schemas": {
"dependsOn": ["prepare"],
"inputs": [
{ "mode": "dependencyOutputs", "globs": ["generated.txt"], "from": ["prepare"] }
]
},
"checks": { "dependsOn": ["schemas"], "inputs": ["package.json"] }
}
}Our real-world {
"tasks": {
"fetch": { "cache": false, "outputs": ["target-fetched/**"] },
"bundle": {
"dependsOn": ["fetch", "^bundle"],
"outputs": ["target/**"],
"inputs": [
"$TURBO_DEFAULT$",
"!target-fetched/**",
{ "mode": "dependencyOutputs", "globs": ["target-fetched/**"], "from": ["fetch"] }
]
}
}
}The one thing that does not carry overUpstream requires the overlap to be declared. There is no automatic detection — So Suggested action: close this PR, migrate our |
`--only` removes dependency edges pointing outside the filter set. Those tasks never run, never produce a task hash, and so contribute nothing to the downstream task's cache key. A change in an excluded package then produces a cache *hit* and turbo restores a stale artifact. Upstream treats the edge dropping as intended behavior for `--only`, and this change does not argue otherwise: the task graph is left exactly as upstream builds it. Instead the engine records each dropped edge, and the excluded package's source hashes are folded in where the missing task hash would have gone. Fresh start from upstream v2.10.6, squashing what remains relevant from #3 (previously released as v2.9.10-hashdepends.2). The dependsOn-output half of that PR is deliberately dropped: upstream solved it in v2.9.17-v2.10.6 via structured `inputs` modes (vercel#13043, vercel#13045, vercel#13125, vercel#13127, vercel#13129), closing vercel#8051. Express those cases with `mode: "jit"` or `mode: "dependencyOutputs"` instead. Notably vercel#13273 removed locking from task hash precomputation, which is the opposite of the `RwLock<HashMap>` the old branch carried. Changes from the previous implementation: - Fold the stand-in hashes into the task's dependency hash list rather than re-hashing the finished task hash. The tracker's copy is then compensated too, so dependents of an affected task also invalidate. This also covers all three hashing paths at once, including `calculate_task_hash_with_deferred_inputs`, which the old post-hoc approach would have missed for tasks that use `jit`/`dependencyOutputs` and have an edge dropped -- the exact combination in our bundle task. - Retain `dropped_dependencies` in `prune_to_reachable` so the compensation survives watch and subgraph runs. - Memoize per-package hashing; several tasks commonly drop the same package, and hashing walks the whole package tree. - Deduplicate dropped edges; a package can be both a topological and a direct dependency. - Pass `repo_index` so hashing uses the repo index fast path (vercel#13213). - Drop the `hash_string` helper, unnecessary under the new approach; turborepo-hash is now untouched by the fork. - Add positive tests that dropped edges are recorded on both the topological and direct-dependency paths, plus one asserting nothing is recorded without `--only`. The old branch tested none of this. - Reword the `#[should_panic]` test as a deliberate tripwire rather than a bug report, and pin its expected panic message. Refs: #1, #2, #3 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This PR including the spec below is all LLM. I don't know Rust. But we depend heavily on Turborepo's caching and vercel#8051 unresolved means we get false positives and thus incorrect builds when task outputs match a dependent task's inputs. Such turbo.json config is valid and not warned against but the suggestion to add a test case was closed a year ago with no activity since.
Because I'm not qualified to review the PR I won't submit it upstream, but we've switched to running releases from here instead of official releases so the PR gets mileage.
If someone finds this PR and can give it a try I'd very much appreciate feedback as comments. See releases for binaries. There's no npm package for the fork.
This PR includes the test case from vercel#10253 by @Leksat
"same turbo run" in the title refers to the workaround: run every task as a new turbo process, in the order of dependsOn in tubo.json, with --only.
Claude Code session summary
Starting point: 22d12e8 (Leksat test case merge)
Set up toolchain (protoc, capnp, lld), built turbo, reproduced the bug (3 runs needed), confirmed tests pass.
Design
Entered plan mode, designed implementation.
First implementation (exclusion approach, later reverted)
Commits on the old branch (superseded):
bbc31de-- detection + warningb029382-- engine-level tests3a7f08d-- exclusion-based cache fix + integration testse33f962-- split warnings into config-level prelude and per-task executionTested in real project, confirmed caching worked (incorrectly, as we later discovered).
Warning refinement
Revealed false positives for packages without fetch scripts.
Found missing directory containment matching, added fix + tests.
Redesigned warnings: config-level deduplicated in prelude, per-task at execution.
Commits on the old branch (superseded):
959a8e9-- warning wording fix + filter per-task warnings to --filter scopeCourse correction: exclusion was wrong
Realized exclusion approach was wrong: it dropped files entirely instead of deferring hashing.
Clean branch
Created clean branch, then force-pushed. Current commits from this point:
1886cab -- ci: release workflow
687116e -- feat: detect overlaps in turbo.json
2a23641 -- fix: defer file hashing
Crash fix
f4e7829 -- fix: rehash tasks whose dependencies were deferred (cascade fix)
Observability
Added debug log in compute_deferred_hash.
Config overlap to info, per-task to debug only.
Dry run
2a75009 -- show DEFERRED in dry run
f5c19ee -- fix to dry-only
Found DEFERRED leaked into build summaries, fixed to dry-only.
93f3512 -- show
<DEPENDS_ON_OUTPUT: example-monorepo-package1#fetch>Naming
7e12f70 -- rename all deferred terminology to depends-on-output
Performance
Found 28 no-op rehashes for packages without fetch scripts.
045beae -- perf: only defer when dep has a script, rename detection to flagging
Spec
Problem
When a task's
inputsinclude files produced by adependsOntask'soutputs, turbo computes file hashes before any task executes. This means:This requires 3 runs to reach stable caching. Additionally, when the dependency has
cache: falseand produces different output each run, the dependent task should miss -- but upstream turbo can't distinguish "file didn't exist yet" from "file content changed."Upstream closed PR #10253 saying this is a breaking change for 3.0.
Solution
Two-layer approach: config-level flagging and depends-on-output hashing.
1. Config-level flagging (
dep_output_overlap.rs)Analyzes turbo.json task definitions to identify patterns where a task's
inputsmatch adependsOntask'soutputs. Uses:dist/**matchesdist/bundle.js)target-schemasmatchestarget-schemas/**)Results are deduplicated by task name (package stripped) and shown as info messages in the run prelude:
This fires regardless of
--filter-- it informs the config author about the pattern.2. Depends-on-output hashing (visitor dispatch)
At runtime, flagged tasks where the dependency package actually has a script get their file hash recomputed at dispatch time -- after dependencies have executed and output files exist on disk. Tasks where the dependency has no script (inherited root config, no-op) preserve upstream behavior.
The mechanism:
output_changed_taskstracking)3. Dry run
In
--drymode, dependencies don't execute, so the hash is based on stale file state. Instead of showing a misleading hash, depends-on-output tasks display:This uses the same
depends_on_output_tasksset -- no separate dry-run code path.Behavior summary
cache: falsedep, output changescache: falsedep, output identical--dry<DEPENDS_ON_OUTPUT: dep#task>Debug observability
With
-vv:Grep:
grep "depends-on-output\|inputs match.*outputs\|DEPENDS_ON_OUTPUT"Test coverage
15 unit tests (
dep_output_overlap.rs):5 integration tests (
dependent_task_hashing.rs):cache: falsedep withdateoutput -> dependent correctly misses5 existing caching tests: all pass unchanged.
Files changed
crates/turborepo-engine/src/dep_output_overlap.rscrates/turborepo-engine/src/lib.rscrates/turborepo-engine/Cargo.tomlcrates/turborepo-lib/src/run/mod.rscrates/turborepo-lib/src/task_graph/visitor/mod.rscrates/turborepo-task-hash/src/lib.rsRwLockon hashes,update_file_hash,set_hashcrates/turborepo/tests/dependent_task_hashing.rsturborepo-tests/integration/fixtures/dependent_task_hashing/.github/workflows/release-binary.yaml