Skip to content

Commit ed8d3c6

Browse files
wan9chicodex
andcommitted
feat(cache): honor runner ignored outputs
Co-authored-by: GPT-5 Codex <codex@openai.com>
1 parent 42f2298 commit ed8d3c6

11 files changed

Lines changed: 148 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

33
- **Added** Runner-aware tools can now call `ignoreInput(path)` to exclude non-semantic reads from auto-inferred task cache inputs.
4+
- **Added** Runner-aware tools can now call `ignoreOutput(path)` to exclude non-semantic writes from read/write overlap checks.
45
- **Changed** Tracked environment values in task cache fingerprints are now stored only as SHA-256 digests, and env-related cache miss details report names without values.
56
- **Added** Runner-aware `getEnvs` match sets can now participate in task cache fingerprints, so changing, adding, or removing a matching env var invalidates the cache ([#450](https://github.com/voidzero-dev/vite-task/pull/450)).
67
- **Added** Runner-aware `getEnvs` calls now return env values served by the runner for matching env glob patterns ([#449](https://github.com/voidzero-dev/vite-task/pull/449)).

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ pub(super) async fn update_cache(
7373
let ignored_input_rels: FxHashSet<RelativePathBuf> = reports
7474
.map(|r| normalize_ignored_paths(&r.ignored_inputs, workspace_root))
7575
.unwrap_or_default();
76+
let ignored_output_rels: FxHashSet<RelativePathBuf> = reports
77+
.map(|r| normalize_ignored_paths(&r.ignored_outputs, workspace_root))
78+
.unwrap_or_default();
7679

7780
if cancelled {
7881
// Cancelled (Ctrl-C or sibling failure) — result is untrustworthy.
@@ -84,8 +87,13 @@ pub(super) async fn update_cache(
8487
return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::NonZeroExitStatus), None);
8588
}
8689

87-
let fspy_outcome =
88-
observe_fspy(outcome, input_negative_globs, &ignored_input_rels, workspace_root);
90+
let fspy_outcome = observe_fspy(
91+
outcome,
92+
input_negative_globs,
93+
&ignored_input_rels,
94+
&ignored_output_rels,
95+
workspace_root,
96+
);
8997

9098
if let Some(TrackingOutcome { read_write_overlap: Some(path), .. }) = &fspy_outcome {
9199
// fspy-inferred read-write overlap: the task wrote to a file it also
@@ -176,6 +184,7 @@ fn observe_fspy(
176184
outcome: &ChildOutcome,
177185
input_negative_globs: Option<&[wax::Glob<'static>]>,
178186
ignored_input_rels: &FxHashSet<RelativePathBuf>,
187+
ignored_output_rels: &FxHashSet<RelativePathBuf>,
179188
workspace_root: &AbsolutePath,
180189
) -> Option<TrackingOutcome> {
181190
#[cfg(fspy)]
@@ -189,14 +198,22 @@ fn observe_fspy(
189198
.into_iter()
190199
.filter(|(path, _)| !is_ignored(path, ignored_input_rels))
191200
.collect();
192-
let read_write_overlap =
193-
path_reads.keys().find(|p| tracked.path_writes.contains(*p)).cloned();
201+
let read_write_overlap = path_reads
202+
.keys()
203+
.find(|p| tracked.path_writes.contains(*p) && !is_ignored(p, ignored_output_rels))
204+
.cloned();
194205
TrackingOutcome { path_reads, read_write_overlap }
195206
})
196207
}
197208
#[cfg(not(fspy))]
198209
{
199-
let _ = (outcome, input_negative_globs, ignored_input_rels, workspace_root);
210+
let _ = (
211+
outcome,
212+
input_negative_globs,
213+
ignored_input_rels,
214+
ignored_output_rels,
215+
workspace_root,
216+
);
200217
None
201218
}
202219
}
@@ -216,14 +233,15 @@ fn collect_tracked_reports(
216233
}
217234

218235
/// Normalize tool-reported absolute paths to workspace-relative. Paths outside
219-
/// the workspace are dropped — they can't contribute to inputs.
236+
/// the workspace are dropped — they can't contribute to inputs or outputs.
220237
fn normalize_ignored_paths(
221238
paths: &FxHashSet<Arc<AbsolutePath>>,
222239
workspace_root: &AbsolutePath,
223240
) -> FxHashSet<RelativePathBuf> {
224241
// On Windows, `workspace_root` may carry a `\\?\` extended-path prefix
225-
// while a tool's `current_dir()`-based ignoreInput path doesn't. Try an
226-
// alternate stripped root too so current-dir paths and fspy paths can meet.
242+
// while a tool's `current_dir()`-based ignoreInput/ignoreOutput path
243+
// doesn't. Try an alternate stripped root too so current-dir paths and
244+
// fspy paths can meet.
227245
#[cfg(windows)]
228246
let workspace_root_stripped: Option<vite_path::AbsolutePathBuf> =
229247
windows_strip_verbatim_prefix(workspace_root.as_path().as_os_str());
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2+
import { ignoreOutput } from '@voidzero-dev/vite-task-client';
3+
4+
mkdirSync('sidecar', { recursive: true });
5+
writeFileSync('sidecar/tmp.txt', 'initial\n');
6+
readFileSync('sidecar/tmp.txt', 'utf8');
7+
writeFileSync('sidecar/tmp.txt', 'final\n');
8+
ignoreOutput('sidecar');
9+
10+
mkdirSync('dist', { recursive: true });
11+
writeFileSync('dist/out.txt', 'ok\n');

crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,35 @@ steps = [
2929
], comment = "cache hit: cache_like/ was ignored via ignoreInput" },
3030
]
3131

32+
[[e2e]]
33+
name = "ignore_output_allows_read_write_overlap"
34+
comment = """
35+
Exercises `ignoreOutput`. The task reads and writes `sidecar/tmp.txt`; without the ignore the runner's read-write overlap check would refuse to cache the run.
36+
"""
37+
ignore = true
38+
steps = [
39+
{ argv = [
40+
"vt",
41+
"run",
42+
"ignore-output",
43+
], comment = "first run populates the cache" },
44+
{ argv = [
45+
"vtt",
46+
"rm",
47+
"dist/out.txt",
48+
], comment = "remove the real output so the cache-hit restore is observable" },
49+
{ argv = [
50+
"vt",
51+
"run",
52+
"ignore-output",
53+
], comment = "cache hit: sidecar/ writes were ignored" },
54+
{ argv = [
55+
"vtt",
56+
"print-file",
57+
"dist/out.txt",
58+
], comment = "restored from the explicit output archive" },
59+
]
60+
3261
[[e2e]]
3362
name = "disable_cache_forces_reexecution"
3463
comment = """
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# ignore_output_allows_read_write_overlap
2+
3+
Exercises `ignoreOutput`. The task reads and writes `sidecar/tmp.txt`; without the ignore the runner's read-write overlap check would refuse to cache the run.
4+
5+
## `vt run ignore-output`
6+
7+
first run populates the cache
8+
9+
```
10+
$ node scripts/ignore_output.mjs
11+
```
12+
13+
## `vtt rm dist/out.txt`
14+
15+
remove the real output so the cache-hit restore is observable
16+
17+
```
18+
```
19+
20+
## `vt run ignore-output`
21+
22+
cache hit: sidecar/ writes were ignored
23+
24+
```
25+
$ node scripts/ignore_output.mjs ◉ cache hit, replaying
26+
27+
---
28+
vt run: cache hit.
29+
```
30+
31+
## `vtt print-file dist/out.txt`
32+
33+
restored from the explicit output archive
34+
35+
```
36+
ok
37+
```

crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
"command": "node scripts/ignore_input.mjs",
55
"cache": true
66
},
7+
"ignore-output": {
8+
"command": "node scripts/ignore_output.mjs",
9+
"output": ["dist/**"],
10+
"cache": true
11+
},
712
"disable-cache": {
813
"command": "node scripts/disable_cache.mjs",
914
"cache": true

crates/vite_task_client/src/lib.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,17 @@ impl Client {
6565
self.send(&Request::IgnoreInput(&ns))
6666
}
6767

68+
/// Fire-and-forget — see [`Self::ignore_input`].
69+
///
70+
/// # Errors
71+
///
72+
/// Returns an error if the request fails to send, or if a relative `path`
73+
/// cannot be resolved against the current working directory.
74+
pub fn ignore_output(&self, path: &OsStr) -> io::Result<()> {
75+
let ns = resolve_path(path)?;
76+
self.send(&Request::IgnoreOutput(&ns))
77+
}
78+
6879
/// Fire-and-forget — see [`Self::ignore_input`].
6980
///
7081
/// # Errors

crates/vite_task_client_napi/src/lib.rs

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,6 @@
2828
clippy::needless_pass_by_value,
2929
reason = "napi bindings require owned std String + std::format! at the JS boundary"
3030
)]
31-
// The no-op methods must keep the exact signature of the real implementations
32-
// that replace them (instance method, fallible return), so the JS-visible API
33-
// shape never changes.
34-
#![expect(
35-
clippy::unused_self,
36-
clippy::unnecessary_wraps,
37-
reason = "no-op stubs keep the signature of the real implementations that replace them"
38-
)]
39-
4031
use std::{collections::HashMap, ffi::OsStr};
4132

4233
use napi::{Error, Result};
@@ -62,11 +53,6 @@ pub struct GetEnvOptions {
6253

6354
/// Handle returned by [`load`]. Holds the IPC connection and exposes the
6455
/// runner-side operations as instance methods.
65-
///
66-
/// The full client surface exists from the start because the npm-published
67-
/// JS wrapper calls these methods unconditionally — they must exist in
68-
/// every runner version. Verbs the runner cannot consume yet are no-ops
69-
/// here and become real requests in the follow-up that consumes them.
7056
#[napi]
7157
pub struct RunnerClient {
7258
client: Client,
@@ -81,10 +67,11 @@ impl RunnerClient {
8167
.map_err(|err| err_string(vite_str::format!("{err}")))
8268
}
8369

84-
/// No-op for now — see [`Self::ignore_input`].
8570
#[napi]
86-
pub fn ignore_output(&self, _path: String) -> Result<()> {
87-
Ok(())
71+
pub fn ignore_output(&self, path: String) -> Result<()> {
72+
self.client
73+
.ignore_output(OsStr::new(&path))
74+
.map_err(|err| err_string(vite_str::format!("{err}")))
8875
}
8976

9077
#[napi]

crates/vite_task_ipc_shared/src/lib.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ pub const NODE_CLIENT_PATH_ENV_NAME: &str = "VP_RUN_NODE_CLIENT_PATH";
1616

1717
/// IPC request frame sent by tools to the runner.
1818
///
19-
/// `IgnoreInput` and `DisableCache` are fire-and-forget: the runner processes
20-
/// them when they arrive and never writes a response. `GetEnv` and `GetEnvs`
21-
/// are round-trips and pair with the matching response types below.
19+
/// `IgnoreInput`, `IgnoreOutput`, and `DisableCache` are fire-and-forget:
20+
/// the runner processes them when they arrive and never writes a response.
21+
/// `GetEnv` and `GetEnvs` are round-trips and pair with the matching response
22+
/// types below.
2223
///
2324
/// Fire-and-forget is safe because nothing in the runner observes individual
2425
/// IPC events live — the recorded set is only consumed *after* the per-task
@@ -28,6 +29,7 @@ pub const NODE_CLIENT_PATH_ENV_NAME: &str = "VP_RUN_NODE_CLIENT_PATH";
2829
#[derive(Debug, SchemaWrite, SchemaRead)]
2930
pub enum Request<'a> {
3031
IgnoreInput(&'a NativeStr),
32+
IgnoreOutput(&'a NativeStr),
3133
GetEnv { name: &'a NativeStr, tracked: bool },
3234
GetEnvs { pattern: &'a str, tracked: bool },
3335
DisableCache,

crates/vite_task_server/src/lib.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use wincode::{SchemaWrite, config::DefaultConfig};
1616

1717
pub trait Handler {
1818
fn ignore_input(&mut self, path: &Arc<AbsolutePath>);
19+
fn ignore_output(&mut self, path: &Arc<AbsolutePath>);
1920
fn disable_cache(&mut self);
2021
fn get_env(&mut self, name: &OsStr, tracked: bool) -> Option<Arc<OsStr>>;
2122
/// Returns the subset of the env map whose names match `pattern` as a glob.
@@ -66,6 +67,7 @@ pub struct InvalidGlob {
6667
/// recover the collected [`Reports`].
6768
pub struct Recorder {
6869
ignored_inputs: FxHashSet<Arc<AbsolutePath>>,
70+
ignored_outputs: FxHashSet<Arc<AbsolutePath>>,
6971
cache_disabled: bool,
7072
tracked_get_env: FxHashMap<Arc<OsStr>, Option<Arc<OsStr>>>,
7173
tracked_get_envs: FxHashMap<Arc<str>, EnvGlobRecord>,
@@ -87,6 +89,7 @@ pub struct EnvGlobRecord {
8789
#[derive(Debug, Default)]
8890
pub struct Reports {
8991
pub ignored_inputs: FxHashSet<Arc<AbsolutePath>>,
92+
pub ignored_outputs: FxHashSet<Arc<AbsolutePath>>,
9093
pub cache_disabled: bool,
9194
pub tracked_get_env: FxHashMap<Arc<OsStr>, Option<Arc<OsStr>>>,
9295
pub tracked_get_envs: FxHashMap<Arc<str>, EnvGlobRecord>,
@@ -97,6 +100,7 @@ impl Recorder {
97100
pub fn new(envs: Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>) -> Self {
98101
Self {
99102
ignored_inputs: FxHashSet::default(),
103+
ignored_outputs: FxHashSet::default(),
100104
cache_disabled: false,
101105
tracked_get_env: FxHashMap::default(),
102106
tracked_get_envs: FxHashMap::default(),
@@ -108,6 +112,7 @@ impl Recorder {
108112
pub fn into_reports(self) -> Reports {
109113
Reports {
110114
ignored_inputs: self.ignored_inputs,
115+
ignored_outputs: self.ignored_outputs,
111116
cache_disabled: self.cache_disabled,
112117
tracked_get_env: self.tracked_get_env,
113118
tracked_get_envs: self.tracked_get_envs,
@@ -120,6 +125,10 @@ impl Handler for Recorder {
120125
self.ignored_inputs.insert(Arc::clone(path));
121126
}
122127

128+
fn ignore_output(&mut self, path: &Arc<AbsolutePath>) {
129+
self.ignored_outputs.insert(Arc::clone(path));
130+
}
131+
123132
fn disable_cache(&mut self) {
124133
self.cache_disabled = true;
125134
}
@@ -374,7 +383,7 @@ async fn handle_client<H: Handler>(mut stream: Stream, handler: &RefCell<H>) ->
374383
let request: Request<'_> =
375384
wincode::deserialize_exact(&buf).map_err(Error::InvalidRequest)?;
376385

377-
// Fire-and-forget branches (`IgnoreInput`, `DisableCache`)
386+
// Fire-and-forget branches (`IgnoreInput`, `IgnoreOutput`, `DisableCache`)
378387
// intentionally write no response. Nothing in the runner observes
379388
// individual IPC events live; the recorded set is collected after
380389
// this driver drains. See `Request` in `vite_task_ipc_shared` for
@@ -384,6 +393,10 @@ async fn handle_client<H: Handler>(mut stream: Stream, handler: &RefCell<H>) ->
384393
let path = native_str_to_abs_path(ns)?;
385394
handler.borrow_mut().ignore_input(&path);
386395
}
396+
Request::IgnoreOutput(ns) => {
397+
let path = native_str_to_abs_path(ns)?;
398+
handler.borrow_mut().ignore_output(&path);
399+
}
387400
Request::DisableCache => {
388401
handler.borrow_mut().disable_cache();
389402
}

0 commit comments

Comments
 (0)