Skip to content

Commit 1b163ee

Browse files
branchseerclaude
andcommitted
feat: show "not cached" message when task modifies its input
Surface the read-write overlap detection in both compact and full summaries so users understand why their task wasn't cached. Compact: "app#build not cached because it modified its input." Full: "→ Not cached: read and wrote 'src/data.txt'" Thread CacheUpdateStatus through the reporter so InputModified (with the overlapping path) reaches SpawnOutcome::Success and the summary rendering. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5619cb0 commit 1b163ee

File tree

14 files changed

+204
-54
lines changed

14 files changed

+204
-54
lines changed

crates/vite_task/src/session/event.rs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use std::{process::ExitStatus, time::Duration};
22

3+
use vite_path::RelativePathBuf;
4+
35
use super::cache::CacheMiss;
46

57
/// The cache operation that failed.
@@ -59,7 +61,10 @@ pub enum CacheNotUpdatedReason {
5961
NonZeroExitStatus,
6062
/// Task modified files it read during execution (read-write overlap detected by fspy).
6163
/// Caching such tasks is unsound because the prerun input hashes become stale.
62-
InputModified,
64+
InputModified {
65+
/// First path that was both read and written during execution.
66+
path: RelativePathBuf,
67+
},
6368
}
6469

6570
#[derive(Debug)]
@@ -69,13 +74,7 @@ pub enum CacheUpdateStatus {
6974
/// Cache was not updated (with reason).
7075
/// The reason is part of the `LeafExecutionReporter` trait contract — reporters
7176
/// can use it for detailed logging, even if current implementations don't.
72-
NotUpdated(
73-
#[expect(
74-
dead_code,
75-
reason = "part of LeafExecutionReporter trait contract; reporters may use for detailed logging"
76-
)]
77-
CacheNotUpdatedReason,
78-
),
77+
NotUpdated(CacheNotUpdatedReason),
7978
}
8079

8180
#[derive(Debug)]

crates/vite_task/src/session/execute/mod.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -403,11 +403,16 @@ pub async fn execute_spawn(
403403
// A task that writes to a glob-matched file without reading it causes
404404
// perpetual cache misses (glob detects the hash change) but not a
405405
// correctness bug, so we don't handle that case here.
406-
if path_accesses
406+
if let Some(path) = path_accesses
407407
.as_ref()
408-
.is_some_and(|pa| pa.path_reads.keys().any(|p| pa.path_writes.contains(p)))
408+
.and_then(|pa| pa.path_reads.keys().find(|p| pa.path_writes.contains(*p)))
409409
{
410-
(CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::InputModified), None)
410+
(
411+
CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::InputModified {
412+
path: path.clone(),
413+
}),
414+
None,
415+
)
411416
} else {
412417
// path_reads is empty when inference is disabled (path_accesses is None)
413418
let empty_path_reads = HashMap::default();

crates/vite_task/src/session/reporter/labeled.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ impl LeafExecutionReporter for LabeledLeafReporter {
251251
async fn finish(
252252
self: Box<Self>,
253253
status: Option<StdExitStatus>,
254-
_cache_update_status: CacheUpdateStatus,
254+
cache_update_status: CacheUpdateStatus,
255255
error: Option<ExecutionError>,
256256
) {
257257
// Convert error before consuming it (need the original for display formatting).
@@ -276,7 +276,12 @@ impl LeafExecutionReporter for LabeledLeafReporter {
276276
task_name: display.task_display.task_name.clone(),
277277
command: display.command.clone(),
278278
cwd: cwd_relative,
279-
result: TaskResult::from_execution(&cache_status, status, saved_error.as_ref()),
279+
result: TaskResult::from_execution(
280+
&cache_status,
281+
status,
282+
saved_error.as_ref(),
283+
&cache_update_status,
284+
),
280285
};
281286

282287
shared.borrow_mut().tasks.push(task_summary);

crates/vite_task/src/session/reporter/summary.rs

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ use crate::session::{
2020
CacheMiss, FingerprintMismatch, InputChangeKind, SpawnFingerprintChange,
2121
detect_spawn_fingerprint_changes, format_input_change_str, format_spawn_change,
2222
},
23-
event::{CacheDisabledReason, CacheErrorKind, CacheStatus, ExecutionError},
23+
event::{
24+
CacheDisabledReason, CacheErrorKind, CacheNotUpdatedReason, CacheStatus, CacheUpdateStatus,
25+
ExecutionError,
26+
},
2427
};
2528

2629
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -94,7 +97,12 @@ pub enum SpawnOutcome {
9497
/// Process exited successfully (exit code 0).
9598
/// May have a post-execution infrastructure error (cache update or fingerprint failed).
9699
/// These only run after exit 0, so this field only exists on the success path.
97-
Success { infra_error: Option<SavedExecutionError> },
100+
Success {
101+
infra_error: Option<SavedExecutionError>,
102+
/// First path that was both read and written, causing cache to be skipped.
103+
/// Only set when fspy detected a read-write overlap.
104+
input_modified_path: Option<Str>,
105+
},
98106

99107
/// Process exited with non-zero status.
100108
/// [`NonZeroI32`] enforces that exit code 0 is unrepresentable here.
@@ -147,6 +155,8 @@ struct SummaryStats {
147155
cache_disabled: usize,
148156
failed: usize,
149157
total_saved: Duration,
158+
/// Display names of tasks that were not cached due to read-write overlap.
159+
input_modified_task_names: Vec<Str>,
150160
}
151161

152162
impl SummaryStats {
@@ -158,6 +168,7 @@ impl SummaryStats {
158168
cache_disabled: 0,
159169
failed: 0,
160170
total_saved: Duration::ZERO,
171+
input_modified_task_names: Vec::new(),
161172
};
162173

163174
for task in tasks {
@@ -175,10 +186,13 @@ impl SummaryStats {
175186
SpawnedCacheStatus::Disabled => stats.cache_disabled += 1,
176187
}
177188
match outcome {
178-
SpawnOutcome::Success { infra_error: Some(_) }
189+
SpawnOutcome::Success { infra_error: Some(_), .. }
179190
| SpawnOutcome::Failed { .. }
180191
| SpawnOutcome::SpawnError(_) => stats.failed += 1,
181-
SpawnOutcome::Success { infra_error: None } => {}
192+
SpawnOutcome::Success { input_modified_path: Some(_), .. } => {
193+
stats.input_modified_task_names.push(task.format_task_display());
194+
}
195+
SpawnOutcome::Success { .. } => {}
182196
}
183197
}
184198
}
@@ -255,25 +269,42 @@ impl TaskResult {
255269
/// `cache_status`: the cache status determined at `start()` time.
256270
/// `exit_status`: the process exit status, or `None` for cache hit / in-process.
257271
/// `saved_error`: an optional pre-converted execution error.
272+
/// `cache_update_status`: the post-execution cache update result.
258273
pub fn from_execution(
259274
cache_status: &CacheStatus,
260275
exit_status: Option<std::process::ExitStatus>,
261276
saved_error: Option<&SavedExecutionError>,
277+
cache_update_status: &CacheUpdateStatus,
262278
) -> Self {
279+
let input_modified_path = match cache_update_status {
280+
CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::InputModified { path }) => {
281+
Some(Str::from(path.as_str()))
282+
}
283+
_ => None,
284+
};
285+
263286
match cache_status {
264287
CacheStatus::Hit { replayed_duration } => {
265288
Self::CacheHit { saved_duration_ms: duration_to_ms(*replayed_duration) }
266289
}
267290
CacheStatus::Disabled(CacheDisabledReason::InProcessExecution) => Self::InProcess,
268291
CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata) => Self::Spawned {
269292
cache_status: SpawnedCacheStatus::Disabled,
270-
outcome: spawn_outcome_from_execution(exit_status, saved_error),
293+
outcome: spawn_outcome_from_execution(
294+
exit_status,
295+
saved_error,
296+
input_modified_path,
297+
),
271298
},
272299
CacheStatus::Miss(cache_miss) => Self::Spawned {
273300
cache_status: SpawnedCacheStatus::Miss(SavedCacheMissReason::from_cache_miss(
274301
cache_miss,
275302
)),
276-
outcome: spawn_outcome_from_execution(exit_status, saved_error),
303+
outcome: spawn_outcome_from_execution(
304+
exit_status,
305+
saved_error,
306+
input_modified_path,
307+
),
277308
},
278309
}
279310
}
@@ -283,13 +314,14 @@ impl TaskResult {
283314
fn spawn_outcome_from_execution(
284315
exit_status: Option<std::process::ExitStatus>,
285316
saved_error: Option<&SavedExecutionError>,
317+
input_modified_path: Option<Str>,
286318
) -> SpawnOutcome {
287319
match (exit_status, saved_error) {
288320
// Spawn error — process never ran
289321
(None, Some(err)) => SpawnOutcome::SpawnError(err.clone()),
290322
// Process exited successfully, possible infra error
291323
(Some(status), _) if status.success() => {
292-
SpawnOutcome::Success { infra_error: saved_error.cloned() }
324+
SpawnOutcome::Success { infra_error: saved_error.cloned(), input_modified_path }
293325
}
294326
// Process exited with non-zero code
295327
(Some(status), _) => {
@@ -304,7 +336,7 @@ fn spawn_outcome_from_execution(
304336
// No exit status, no error — this is the cache hit / in-process path,
305337
// handled by TaskResult::CacheHit / InProcess before reaching here.
306338
// If we somehow get here, treat as success.
307-
(None, None) => SpawnOutcome::Success { infra_error: None },
339+
(None, None) => SpawnOutcome::Success { infra_error: None, input_modified_path: None },
308340
}
309341
}
310342

@@ -415,6 +447,15 @@ impl TaskResult {
415447
/// - "→ Cache miss: no previous cache entry found"
416448
/// - "→ Cache disabled in task configuration"
417449
fn format_cache_detail(&self) -> Str {
450+
// Check for input modification first — it overrides the cache miss reason
451+
if let Self::Spawned {
452+
outcome: SpawnOutcome::Success { input_modified_path: Some(path), .. },
453+
..
454+
} = self
455+
{
456+
return vite_str::format!("→ Not cached: read and wrote '{path}'");
457+
}
458+
418459
match self {
419460
Self::CacheHit { saved_duration_ms } => {
420461
let d = Duration::from_millis(*saved_duration_ms);
@@ -467,7 +508,7 @@ impl TaskResult {
467508
match self {
468509
Self::CacheHit { .. } | Self::InProcess => None,
469510
Self::Spawned { outcome, .. } => match outcome {
470-
SpawnOutcome::Success { infra_error } => infra_error.as_ref(),
511+
SpawnOutcome::Success { infra_error, .. } => infra_error.as_ref(),
471512
SpawnOutcome::Failed { .. } => None,
472513
SpawnOutcome::SpawnError(err) => Some(err),
473514
},
@@ -671,8 +712,8 @@ pub fn format_compact_summary(summary: &LastRunSummary, program_name: &str) -> V
671712

672713
let is_single_task = summary.tasks.len() == 1;
673714

674-
// Single task + not cache hit → no summary
675-
if is_single_task && stats.cache_hits == 0 {
715+
// Single task + not cache hit + no input modification → no summary
716+
if is_single_task && stats.cache_hits == 0 && stats.input_modified_task_names.is_empty() {
676717
return Vec::new();
677718
}
678719

@@ -682,16 +723,18 @@ pub fn format_compact_summary(summary: &LastRunSummary, program_name: &str) -> V
682723
let _ = writeln!(buf, "{}", "---".style(Style::new().bright_black()));
683724

684725
let run_label = vite_str::format!("{program_name} run:");
685-
if is_single_task {
686-
// Single task cache hit
726+
let mut show_last_details_hint = true;
727+
if is_single_task && stats.cache_hits > 0 {
728+
// Single task cache hit — no need for --last-details hint
687729
let formatted_total_saved = format_summary_duration(stats.total_saved);
688-
let _ = writeln!(
730+
let _ = write!(
689731
buf,
690732
"{} cache hit, {} saved.",
691733
run_label.as_str().style(Style::new().blue().bold()),
692734
formatted_total_saved.style(Style::new().green().bold()),
693735
);
694-
} else {
736+
show_last_details_hint = false;
737+
} else if !is_single_task {
695738
// Multi-task
696739
let total = stats.total;
697740
let hits = stats.cache_hits;
@@ -727,12 +770,42 @@ pub fn format_compact_summary(summary: &LastRunSummary, program_name: &str) -> V
727770
let _ = write!(buf, ", {} failed", n.style(Style::new().red()));
728771
}
729772

773+
let _ = write!(buf, ".");
774+
} else {
775+
// Single task, no cache hit — only shown when input_modified is non-empty
776+
let _ = write!(buf, "{}", run_label.as_str().style(Style::new().blue().bold()));
777+
}
778+
779+
// Inline input-modified notice before the --last-details hint
780+
if !stats.input_modified_task_names.is_empty() {
781+
format_input_modified_notice(&mut buf, &stats.input_modified_task_names);
782+
}
783+
784+
if show_last_details_hint {
730785
let last_details_cmd = vite_str::format!("`{program_name} run --last-details`");
731-
let _ = write!(buf, ". {}", "(Run ".style(Style::new().bright_black()));
786+
let _ = write!(buf, " {}", "(Run ".style(Style::new().bright_black()));
732787
let _ = write!(buf, "{}", last_details_cmd.as_str().style(COMMAND_STYLE));
733788
let _ = write!(buf, "{}", " for full details)".style(Style::new().bright_black()));
734-
let _ = writeln!(buf);
735789
}
790+
let _ = writeln!(buf);
736791

737792
buf
738793
}
794+
795+
/// Write the "not cached because it modified its input" notice inline.
796+
fn format_input_modified_notice(buf: &mut Vec<u8>, task_names: &[Str]) {
797+
let _ = write!(buf, " ");
798+
799+
let first = &task_names[0];
800+
let _ = write!(buf, "{}", first.as_str().style(Style::new().bold()));
801+
let remaining = task_names.len() - 1;
802+
if remaining > 0 {
803+
let _ = write!(buf, " (and {remaining} more)");
804+
}
805+
806+
if task_names.len() == 1 {
807+
let _ = write!(buf, " not cached because it modified its input.");
808+
} else {
809+
let _ = write!(buf, " not cached because they modified their inputs.");
810+
}
811+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "@test/normal-pkg",
3+
"scripts": {
4+
"task": "print hello"
5+
}
6+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "@test/rw-pkg",
3+
"scripts": {
4+
"task": "replace-file-content src/data.txt original modified"
5+
},
6+
"dependencies": {
7+
"@test/normal-pkg": "workspace:*"
8+
}
9+
}

crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/src/data.txt renamed to crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/rw-pkg/src/data.txt

File renamed without changes.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
packages:
2+
- packages/*
Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1-
# Test that tasks modifying their own inputs (read-write overlap) are not cached.
1+
# Tests that tasks modifying their own inputs (read-write overlap) are not cached.
22
# replace-file-content reads then writes the same file — fspy detects both.
3-
# Without this behavior, the second run would be a cache hit.
43

4+
# Single rw-task: compact summary shows "not cached because it modified its input"
55
[[e2e]]
6-
name = "read-write task is not cached"
7-
steps = [
8-
# First run - executes, but cache NOT stored (read-write overlap detected)
9-
"vt run rw-task",
10-
# Second run - still miss (no cache was stored on first run)
11-
"vt run rw-task",
12-
]
6+
name = "single read-write task shows not cached message"
7+
cwd = "packages/rw-pkg"
8+
steps = ["vt run task", "vt run task"]
9+
10+
# Multi-task (recursive): compact summary shows stats + InputModified notice
11+
[[e2e]]
12+
name = "multi task with read-write shows not cached in summary"
13+
steps = ["vt run -r task", "vt run -r task"]
14+
15+
# Verbose: full summary shows the overlapping path
16+
[[e2e]]
17+
name = "verbose read-write task shows path in full summary"
18+
cwd = "packages/rw-pkg"
19+
steps = ["vt run -v task"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
source: crates/vite_task_bin/tests/e2e_snapshots/main.rs
3+
expression: e2e_outputs
4+
---
5+
> vt run -r task
6+
~/packages/normal-pkg$ print hello
7+
hello
8+
9+
~/packages/rw-pkg$ replace-file-content src/data.txt original modified
10+
11+
---
12+
vt run: 0/2 cache hit (0%). @test/rw-pkg#task not cached because it modified its input. (Run `vt run --last-details` for full details)
13+
> vt run -r task
14+
~/packages/normal-pkg$ print hellocache hit, replaying
15+
hello
16+
17+
~/packages/rw-pkg$ replace-file-content src/data.txt original modified
18+
19+
---
20+
vt run: 1/2 cache hit (50%), <duration> saved. @test/rw-pkg#task not cached because it modified its input. (Run `vt run --last-details` for full details)

0 commit comments

Comments
 (0)