From 7753da4ac9a0e22491651fe6a6c5ba96acc7c85f Mon Sep 17 00:00:00 2001 From: Ram Vinjamuri Date: Fri, 18 Sep 2026 15:11:27 -0700 Subject: [PATCH 1/5] Record what a tool is about to touch, from the pre-tool hook Anything scheduling work alongside an agent needs to know which paths the agent is in right now. Predicting that from a plan does not work: asked to declare its writes, one agent named a single file having written five, and a planned node predicted one path against nine observed. The pre-tool hook is already handed the exact path before the edit lands, so it records it. Two placement decisions, both forced by measurement. It rides on the existing pre-tool hook rather than being a hook of its own. A separate hook process costs about as much as this whole binary's hook path, so a second one roughly doubles what every Edit, Write and Bash pays to record a path this process already holds. Here it is one append. It writes into the store, not the repo. The obvious placement, /.speculation/leases, took the hook from 65ms to 120ms with a daemon running: a write inside the tree wakes the watcher, and this hook then waits for the checkpoint its own write caused. It would also have appeared in every blast radius as a changed path. Outside the tree, neither happens, and the cost is 1.4ms on a 65ms hook. A missing path records a wildcard, because a Bash command can touch anything and a reader should block rather than guess. ACYCLIC_NO_LEASES turns the whole thing off without a rebuild, which is also what made the measurement honest. --- crates/acyclic/src/hook.rs | 79 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/crates/acyclic/src/hook.rs b/crates/acyclic/src/hook.rs index 6f7e3e1..39b45bd 100644 --- a/crates/acyclic/src/hook.rs +++ b/crates/acyclic/src/hook.rs @@ -40,6 +40,19 @@ struct Payload { /// run, standing in for `tool_name` when that field is absent. #[serde(default)] command: Option, + /// `PreToolUse`: what the tool is about to touch. Edit/Write/MultiEdit + /// name a path here; Bash does not, and a shell command can touch + /// anything — which is why a missing path records a wildcard. + #[serde(default)] + tool_input: Option, +} + +#[derive(Debug, Default, serde::Deserialize)] +struct ToolInput { + #[serde(default)] + file_path: Option, + #[serde(default)] + path: Option, } impl Payload { @@ -132,6 +145,18 @@ pub fn run(repo: &Path, event: &str) -> i32 { let mut payload = parse_payload(&raw); let host = std::env::var("ACYCLIC_HOST").unwrap_or_else(|_| "claude-code".into()); + // Before the connect, deliberately. A lease says what the agent is about + // to touch, and that is worth recording whether or not a daemon is up — + // the connect returns early when there is none, so recording afterwards + // would silently stop working exactly when checkpointing is off. + if event == HookEvent::PreTool { + let path = payload + .tool_input + .as_ref() + .and_then(|i| i.file_path.as_deref().or(i.path.as_deref())); + record_lease(repo, payload.tool_name.as_deref(), path); + } + // A session start may spawn the daemon, but never waits for its first // snapshot: the agent's first turn is behind this hook. let spawn = if event == HookEvent::SessionStart { @@ -239,6 +264,60 @@ fn parse_payload(raw: &str) -> Payload { serde_json::from_str(raw).unwrap_or_default() } +/// Record what the agent is *about* to touch, for anything scheduling work +/// alongside it. +/// +/// Two decisions here, both forced by measurement. +/// +/// **It rides on the pre-tool hook** rather than being a hook of its own. A +/// separate hook process measured ~10ms at best against this binary's own +/// ~10ms, so a second hook roughly doubles what every Edit, Write and Bash +/// pays — to record a path this process is already holding. Here the marginal +/// cost is one append. +/// +/// **It writes into the STORE, not the repo.** The obvious placement, +/// `/.speculation/leases`, took the pre-tool hook from 65ms to 120ms with +/// a daemon running: a write inside the tree wakes the watcher, and this very +/// hook then waits for the resulting checkpoint. The lease write became work +/// the lease writer waited on. It would also have shown up in every blast +/// radius as a changed path. Outside the tree, neither happens. +/// +/// Every failure is swallowed. A hook may not break a tool call, and a missing +/// lease only means a speculator schedules more conservatively. +fn record_lease(repo: &Path, tool: Option<&str>, path: Option<&str>) { + use std::io::Write; + + // An off switch, because this sits on the agent's critical path. Anything + // that runs on every Edit, Write and Bash should be disableable without a + // rebuild. + if std::env::var_os("ACYCLIC_NO_LEASES").is_some() { + return; + } + let Ok(paths) = crate::store_paths(repo) else { + return; + }; + let at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + // Repo-relative, and no tab: the file is tab separated and a reader must + // never mis-split a line. + let path = path + .map(|p| { + p.trim_start_matches(&*repo.to_string_lossy()) + .trim_start_matches('/') + }) + .filter(|p| !p.is_empty() && !p.contains('\t')) + .unwrap_or("*"); + let tool = tool.filter(|t| !t.contains('\t')).unwrap_or("?"); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(paths.root.join("leases")) + { + let _ = writeln!(f, "{at}\t{tool}\t{path}"); + } +} + fn connect(repo: &Path, spawn: Spawn) -> Result { let paths = crate::store_paths(repo).map_err(ConnectError::Other)?; let log = paths.root.join("daemon.log"); From bbf041993986e9545fe93762cd780740dc878080 Mon Sep 17 00:00:00 2001 From: Ram Vinjamuri Date: Fri, 18 Sep 2026 15:23:01 -0700 Subject: [PATCH 2/5] Say what went wrong, cap `turns`, and spell out how `exclude` matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small things, each one a paper cut hit while driving the CLI from scripts. A daemon that exits mid-answer closes the socket, so the read succeeds with nothing and serde called that "decode: EOF while parsing a value at line 1 column 0". That reads like corruption; it means the daemon stopped. Running `stop` and then any other verb was enough to see it. The response parse moves into its own function so the shutdown case is covered by a test rather than a timing-dependent race — it could not be reproduced on demand in six tries. `turns` had no `--limit` while `timeline` did, so a long session printed everything. Trimmed on the rendering side, since the daemon already answers with the session's turns and a limit is a display concern, and the trim says how many turns it hid rather than quietly dropping history. `exclude` matches paths, not names, and the wrong form fails silently while looking like it worked: exclude = ["__pycache__"] leaves src/__pycache__ captured. The README now says so, with the measured cost of getting it wrong on a Rust tree — 1.4 GB of store and +29s per build against 14 MB and 35s. Also hardens the lease writer from the previous commit: it creates the store root if a tool call precedes `init`, and six tests cover repo-relative paths, the wildcard for tools that name no file, tab rejection, the off switch, append behaviour, and — the regression that cost 65ms to 120ms — that nothing is ever written inside the repo. --- README.md | 2 + crates/acyclic/src/client.rs | 84 +++++++++++++++++++++++++-- crates/acyclic/src/hook.rs | 108 +++++++++++++++++++++++++++++++++++ crates/acyclic/src/main.rs | 16 +++++- 4 files changed, 202 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index af55407..7c729fb 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ Not yet covered: an `install` writer for Codex's MCP config (TOML), Kimi Code CL Speculation is configured separately, in `~/.config/acyclic/speculate.toml` — per developer, never checked in, because turning it on can spend that developer's money. See [Speculation](#speculation). +`exclude` matches **paths, not names**: `exclude = ["__pycache__"]` excludes a top-level `__pycache__/` and nothing else — it will not exclude `src/__pycache__/`. Name every path you mean (`"src/__pycache__"`), or exclude the directory that contains them. The wrong form fails silently and looks like it worked: the build output is captured anyway, and a Rust `target/` measured 1.4 GB of store and +29 s per build against 14 MB and 35 s with it excluded. + Adding a path to `exclude` takes effect at the next daemon start; the baseline it builds is scrubbed, and every later checkpoint skips the path. Generations captured before the rule still hold it (see below). ## Speculation diff --git a/crates/acyclic/src/client.rs b/crates/acyclic/src/client.rs index 79606a6..69a2bc5 100644 --- a/crates/acyclic/src/client.rs +++ b/crates/acyclic/src/client.rs @@ -144,12 +144,26 @@ impl Client { self.stream .read_line(&mut response_line) .map_err(|error| format!("receive: {error}"))?; - let response: proto::Response = - serde_json::from_str(&response_line).map_err(|error| format!("decode: {error}"))?; - match response.payload { - proto::Payload::Ok(reply) => Ok(*reply), - proto::Payload::Err { message } => Err(message), - } + // A daemon that exits mid-answer closes the socket, so the read + // succeeds with nothing. Left to serde that surfaced as + // "decode: EOF while parsing a value at line 1 column 0", which reads + // like corruption rather than what it is: the daemon stopped. Anyone + // running `stop` and then any other verb hit it. + parse_response(&response_line) + } +} + +/// One response line to a reply. Split out from the socket so the +/// shutdown case can be tested without a daemon. +fn parse_response(line: &str) -> Result { + if line.trim().is_empty() { + return Err("daemon stopped while answering; nothing was recorded".to_owned()); + } + let response: proto::Response = + serde_json::from_str(line).map_err(|error| format!("decode: {error}"))?; + match response.payload { + proto::Payload::Ok(reply) => Ok(*reply), + proto::Payload::Err { message } => Err(message), } } @@ -335,3 +349,61 @@ fn wait_for_socket( std::thread::sleep(Duration::from_millis(200)); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_closed_socket_says_the_daemon_stopped() { + // The regression: a daemon that exits mid-answer closes the socket, the + // read succeeds with nothing, and serde called that + // "decode: EOF while parsing a value at line 1 column 0" — which reads + // as corruption. Anyone running `stop` then any other verb saw it. + for line in ["", "\n", " \n"] { + let error = parse_response(line).expect_err("empty must be an error"); + assert!( + error.contains("daemon stopped"), + "unhelpful message for {line:?}: {error}" + ); + assert!(!error.contains("decode"), "leaked serde wording: {error}"); + } + } + + #[test] + fn malformed_json_still_reports_a_decode_error() { + // Genuine corruption must stay distinguishable from a clean shutdown. + let error = parse_response("{not json").expect_err("must be an error"); + assert!(error.starts_with("decode:"), "{error}"); + } + + #[test] + fn an_error_payload_surfaces_its_own_message() { + // Built from the protocol types and serialized, rather than a + // hand-written literal: the payload is flattened and renamed, so a + // literal here would test my guess at the wire format instead of the + // format. The first attempt did exactly that and failed. + let line = serde_json::to_string(&proto::Response { + id: 1, + payload: proto::Payload::Err { + message: "no such checkpoint".to_owned(), + }, + }) + .expect("serialize"); + let error = parse_response(&line).expect_err("must be an error"); + assert_eq!(error, "no such checkpoint"); + } + + #[test] + fn an_ok_payload_round_trips() { + let line = serde_json::to_string(&proto::Response { + id: 1, + payload: proto::Payload::Ok(Box::new(proto::Reply::Pong)), + }) + .expect("serialize"); + assert!(matches!( + parse_response(&line).expect("ok payload"), + proto::Reply::Pong + )); + } +} diff --git a/crates/acyclic/src/hook.rs b/crates/acyclic/src/hook.rs index 39b45bd..e87b762 100644 --- a/crates/acyclic/src/hook.rs +++ b/crates/acyclic/src/hook.rs @@ -296,6 +296,11 @@ fn record_lease(repo: &Path, tool: Option<&str>, path: Option<&str>) { let Ok(paths) = crate::store_paths(repo) else { return; }; + // The store root exists after `init`, but a lease is worth recording from + // the very first tool call, which can precede it. + if std::fs::create_dir_all(&paths.root).is_err() { + return; + } let at = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_secs()); @@ -377,4 +382,107 @@ mod tests { assert!(payload.tool_name.is_none()); } } + + /// A scratch repo whose store lives beside it, so `record_lease` writes + /// somewhere real and nothing touches the developer's own stores. The + /// store root comes from the repo's own config, so that is where the + /// redirect goes — there is no env override, deliberately. + fn scratch() -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let repo = dir.path().join("repo"); + std::fs::create_dir_all(repo.join(".acyclic")).expect("repo"); + let stores = dir.path().join("stores"); + std::fs::create_dir_all(&stores).expect("stores"); + std::fs::write( + repo.join(".acyclic/config.toml"), + format!("store_dir = {:?}\n", stores.to_string_lossy()), + ) + .expect("config"); + // The store root is created lazily by `init`; the lease writer must + // work before that, which is what create_dir_all in it is for. + (dir, repo) + } + + fn leases_of(repo: &Path) -> String { + let paths = crate::store_paths(repo).expect("store paths"); + std::fs::read_to_string(paths.root.join("leases")).unwrap_or_default() + } + + #[test] + fn a_path_is_recorded_repo_relative() { + let (_dir, repo) = scratch(); + let absolute = repo.join("src/report.py"); + record_lease(&repo, Some("Edit"), Some(&absolute.to_string_lossy())); + record_lease(&repo, Some("Write"), Some("src/money.py")); + let text = leases_of(&repo); + // Absolute and relative inputs both land relative: a reader compares + // these against paths from `diff`, which are repo-relative. + assert!( + text.contains("\tEdit\tsrc/report.py\n"), + "absolute path not made relative: {text}" + ); + assert!(text.contains("\tWrite\tsrc/money.py\n"), "{text}"); + } + + #[test] + fn a_tool_with_no_path_records_a_wildcard() { + let (_dir, repo) = scratch(); + // Bash names no file and can touch anything, so a reader must block + // rather than guess. + record_lease(&repo, Some("Bash"), None); + assert!( + leases_of(&repo).contains("\tBash\t*\n"), + "{}", + leases_of(&repo) + ); + } + + #[test] + fn a_tab_in_either_field_is_refused() { + let (_dir, repo) = scratch(); + // The file is tab separated. A tab smuggled in through a filename + // would make a reader mis-split the line and treat junk as a path. + record_lease(&repo, Some("Ed\tit"), Some("src/a\tb.py")); + let text = leases_of(&repo); + assert!(text.contains("\t?\t*\n"), "tabs not neutralised: {text}"); + assert_eq!(text.lines().count(), 1, "one line per call: {text}"); + } + + #[test] + fn the_kill_switch_writes_nothing() { + let (_dir, repo) = scratch(); + std::env::set_var("ACYCLIC_NO_LEASES", "1"); + record_lease(&repo, Some("Edit"), Some("src/report.py")); + std::env::remove_var("ACYCLIC_NO_LEASES"); + assert!( + leases_of(&repo).is_empty(), + "the off switch must be an off switch" + ); + } + + #[test] + fn leases_never_land_inside_the_repo() { + let (_dir, repo) = scratch(); + record_lease(&repo, Some("Edit"), Some("src/report.py")); + // The regression this guards: writing into the tree woke the watcher, + // and the pre-tool hook then waited for the checkpoint its own write + // caused — 65ms to 120ms. It would also have shown up in every blast + // radius as a changed path. + assert!( + !repo.join(".speculation").exists(), + "a lease inside the repo is captured by the watcher and inflates \ + every diff" + ); + } + + #[test] + fn appending_keeps_earlier_lines() { + let (_dir, repo) = scratch(); + for path in ["a.py", "b.py", "c.py"] { + record_lease(&repo, Some("Edit"), Some(path)); + } + // A reader takes the live window by timestamp, so history must not be + // truncated by a later write. + assert_eq!(leases_of(&repo).lines().count(), 3, "{}", leases_of(&repo)); + } } diff --git a/crates/acyclic/src/main.rs b/crates/acyclic/src/main.rs index 4fd8d55..e2d0166 100644 --- a/crates/acyclic/src/main.rs +++ b/crates/acyclic/src/main.rs @@ -91,6 +91,11 @@ enum Command { Turns { #[arg(long)] session: Option, + /// Newest first, like `timeline --limit`. A long session has one turn + /// per prompt, so the default is the recent history rather than all of + /// it. + #[arg(long, default_value_t = 50)] + limit: usize, }, /// One checkpoint resolved to its session, turn, and prompt. Show { checkpoint: i64 }, @@ -669,7 +674,7 @@ fn execute(client: &mut Client, command: Command) -> Result<(), String> { } Ok(()) } - Command::Turns { session } => { + Command::Turns { session, limit } => { let reply = client.call(proto::Op::Turns { session_id: session, })?; @@ -680,7 +685,11 @@ fn execute(client: &mut Client, command: Command) -> Result<(), String> { println!("no turns recorded (the user-prompt hook records them)"); return Ok(()); } - for turn in turns { + // Trimmed here rather than in the protocol: the daemon already + // answers with the session's turns, and a limit is a display + // concern. Newest first, matching `timeline`. + let hidden = turns.len().saturating_sub(limit); + for turn in turns.into_iter().take(limit) { let range = match (turn.first_checkpoint, turn.last_checkpoint) { (Some(first), Some(last)) if first != last => format!("#{first}..#{last}"), (Some(first), _) => format!("#{first}"), @@ -695,6 +704,9 @@ fn execute(client: &mut Client, command: Command) -> Result<(), String> { brief::quote(&turn.prompt, 72), ); } + if hidden > 0 { + println!("… {hidden} older turn(s) not shown (--limit)"); + } Ok(()) } Command::Show { checkpoint } => { From 6d516966a86b6f309a7d0ee27315871fa149d499 Mon Sep 17 00:00:00 2001 From: Ram Vinjamuri Date: Fri, 18 Sep 2026 17:48:58 -0700 Subject: [PATCH 3/5] Record that `exclude` does not govern what a fork writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A speculative agent working inside a fork mount, with no shell and no LSP tool, still ended up with a target/ in its fork: the host's edit-time diagnostics ran cargo check for it. Nothing consults the exclude set on a fork's overlay writes, so all of it went into the object store — 1.3 GB in five minutes on a 2.4 MB source tree — and promote then snapshotted the fork, target/ and all, for another 2.2 GB in one minute. The backend answered "Objects capacity exhausted", the rewind's cleanup hit the same wall, and the daemon fell into a recovery rescan it could not finish. Ten minutes after init, restore and checkpoint both failed. The design already says forks do not see excluded paths, and they do not: the base generation holds none. The gap is paths created inside the fork. The write-up records the store's own growth by minute, the promote message that shows the seam from the other side ("target is excluded from snapshots; no checkpoint holds it" — after capturing it as a fork path), and three fixes in order of how much they change: apply exclude to fork writes, have promote skip excluded paths, refuse a snapshot before it can exhaust the store. --- docs/design/03-forks.md | 3 + docs/design/05-monorepo.md | 4 +- docs/design/fork-writes-bypass-exclude.md | 89 +++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 docs/design/fork-writes-bypass-exclude.md diff --git a/docs/design/03-forks.md b/docs/design/03-forks.md index 7457dd3..c02cc38 100644 --- a/docs/design/03-forks.md +++ b/docs/design/03-forks.md @@ -73,6 +73,9 @@ is what exists, including where it diverged from the plan. ## Notes - Fork orchestration of subagents is uniquely plugin-shaped — it must live inside the host. +- `exclude` does not apply to paths created inside a fork: build output written into a mount is + captured in full and snapshotted by `promote`, which wedged a store in one live run. See + [fork-writes-bypass-exclude.md](fork-writes-bypass-exclude.md). - Filesystem-layer enforcement for Launch 4's guarded paths arrives with the mount option. This turned out to be the decisive argument: the mount shipped and reflinks never did, and Safe Mode refuses to start without a mount provider. ## Open questions (not yet settled) diff --git a/docs/design/05-monorepo.md b/docs/design/05-monorepo.md index cc753a6..b5d850f 100644 --- a/docs/design/05-monorepo.md +++ b/docs/design/05-monorepo.md @@ -75,7 +75,9 @@ which reads as though Launch 5 exists. answer it gives will be silently incomplete rather than refused. *No lean.* 4. **Forks and Safe Mode sessions do not see excluded paths either**, which bears directly on "searches against a fork must answer from that tree's - state". *No lean.* + state". *No lean.* The converse is now a known gap: paths *created* inside + a fork are captured regardless of `exclude` — see + [fork-writes-bypass-exclude.md](fork-writes-bypass-exclude.md). 5. **Does the name change?** Calling this "the index engine" collides with the shipped metadata index. *Current lean: rename this launch, not the shipped component.* diff --git a/docs/design/fork-writes-bypass-exclude.md b/docs/design/fork-writes-bypass-exclude.md new file mode 100644 index 0000000..0da5c8a --- /dev/null +++ b/docs/design/fork-writes-bypass-exclude.md @@ -0,0 +1,89 @@ +# Finding: `exclude` does not govern what a fork writes + +**Status: observed, reproducible, not fixed.** Recorded 2026-09-18 from a live +run against this repo. The numbers below are from the object store's own +timestamps, not from estimates. + +## What happened + +A speculative agent worked inside a fork mount of this repo (`acyclic fork -n 1`) +with `exclude = ["target"]` in `.acyclic/config.toml`. The agent had no shell +and no LSP tool. At T+32s a `target/` directory appeared in the fork anyway — +the host's edit-time diagnostics ran `cargo check` on the agent's behalf. No +tool permission controls that; it is the host's own machinery. + +The object store then grew like this, on a repo whose tracked source is 2.4 MB: + +``` +T+1–5 min 1,267 MB 106,040 objects while the agent worked in the fork +T+8 min 2,259 MB 19,924 objects `acyclic promote` snapshotting the fork +T+9–10 min 566 MB 40,474 objects the rewind, then a recovery rescan +total 4.6 GB 171,485 objects +``` + +At the promote the backend answered `Objects capacity exhausted`. The +rewind's exclusion cleanup hit the same wall, the daemon fell into +`baselining`, and every subsequent operation — `restore`, `checkpoint`, +`status` — failed with the same error. The store was unusable ten minutes +after `init`. + +## Why + +`exclude` is applied when the main tree is captured: excluded paths never +enter a checkpoint, and [05-monorepo.md](05-monorepo.md) notes that forks and +Safe Mode sessions do not *see* excluded paths, because the base generation +does not hold them. That is consistent with what was observed — the fork +started without a `target/`. + +What is not covered is a path that is *created inside the fork*. A fork is a +writable overlay backed by the object store, so every write lands in the +store as it happens. Nothing consults the exclude set on that path. Build +output written into a fork is therefore captured in full, and `promote` +snapshots the fork — `target/` included — before landing it. The first promote +in this run reported exactly that seam from the other side: + +``` +merge stopped while landing 3 path(s): restore: target is excluded from +snapshots (`exclude` in .acyclic/config.toml); no checkpoint holds it. +``` + +It had captured `target` as a fork path, then refused to restore it as an +excluded one. + +## Why it matters more than it looks + +- On a Rust tree, one `cargo check` is enough. The fork's `target/` was 1,391 + paths; three concurrent forks in an earlier run reached 3.8 GB the same way. +- The write is not the agent's choice. Denying Bash and LSP did not prevent + it, because the host's diagnostics are not a tool the agent invokes. +- The failure is not a slow store, it is a wedged one: capacity exhaustion + stops `restore` and the rewind path, which are the recovery tools. +- The design's own guidance — "speculation may use idle capacity and must + never compete for busy capacity" — cannot hold if a fork's build output is + hashed at full rate while the fork is in use. + +## What would fix it + +In rough order of how much they change: + +1. **Apply the exclude set to fork writes.** A path that would be excluded + from the main tree's capture should be excluded from a fork's overlay too: + written through to a scratch location, never stored, never snapshotted. + This is the fix that matches what `exclude` already means. +2. **Have `promote` skip excluded paths** rather than stop on them. Necessary + even with (1), for forks created before the rule landed. +3. **Refuse the snapshot before exhausting the store.** A promote that is + about to capture an excluded directory of a thousand paths should say so + and stop, not discover capacity limits halfway through. + +Until one of these lands, the operational workaround is to remove excluded +directories from a fork before promoting it, and to keep build output out of +fork mounts entirely — which no permission setting can guarantee. + +## Related + +- [03-forks.md](03-forks.md) — fork design; this is a gap in what the overlay + captures, not in how it isolates. +- [05-monorepo.md](05-monorepo.md) — exclude semantics for the main tree. +- README, *Configuration* — `exclude` is path-based; this finding is the other + half of that: it is also main-tree-only. From 384189c1c3ff6e3d5d7b56a4c0ee5000238c60ea Mon Sep 17 00:00:00 2001 From: Ram Vinjamuri Date: Sat, 19 Sep 2026 10:45:41 -0700 Subject: [PATCH 4/5] Test fixture: build the repo config path from product::, not a literal The product-name guard in CI caught a hardcoded `.acyclic` in the lease writer's test fixture. The helpers for exactly this already exist: product::repo_config_dir() and repo_config_file(). --- crates/acyclic/src/hook.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/acyclic/src/hook.rs b/crates/acyclic/src/hook.rs index e87b762..da3ba9d 100644 --- a/crates/acyclic/src/hook.rs +++ b/crates/acyclic/src/hook.rs @@ -390,11 +390,12 @@ mod tests { fn scratch() -> (tempfile::TempDir, std::path::PathBuf) { let dir = tempfile::tempdir().expect("tempdir"); let repo = dir.path().join("repo"); - std::fs::create_dir_all(repo.join(".acyclic")).expect("repo"); + std::fs::create_dir_all(repo.join(acyclic_engine::product::repo_config_dir())) + .expect("repo"); let stores = dir.path().join("stores"); std::fs::create_dir_all(&stores).expect("stores"); std::fs::write( - repo.join(".acyclic/config.toml"), + repo.join(acyclic_engine::product::repo_config_file()), format!("store_dir = {:?}\n", stores.to_string_lossy()), ) .expect("config"); From a3b5339ad3b2e0e2dda5ca56b6f4717e4d46da0a Mon Sep 17 00:00:00 2001 From: Ram Vinjamuri Date: Sat, 19 Sep 2026 10:52:25 -0700 Subject: [PATCH 5/5] Leases: relativise the path by component, not by string prefix Stripping the repo as a string and then trimming '/' left a leading backslash on Windows (CI: '\src/report.py'), and a real Windows host would have recorded 'src\report.py', which never matches a path from `diff`. Strip the prefix as a Path and join the components with '/'. --- crates/acyclic/src/hook.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/acyclic/src/hook.rs b/crates/acyclic/src/hook.rs index da3ba9d..cd4c787 100644 --- a/crates/acyclic/src/hook.rs +++ b/crates/acyclic/src/hook.rs @@ -304,15 +304,20 @@ fn record_lease(repo: &Path, tool: Option<&str>, path: Option<&str>) { let at = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_secs()); - // Repo-relative, and no tab: the file is tab separated and a reader must - // never mis-split a line. + // Repo-relative with `/` separators whatever the host wrote, and no tab: + // a reader compares these against paths from `diff` and the file is tab + // separated, so a line must never mis-split. let path = path .map(|p| { - p.trim_start_matches(&*repo.to_string_lossy()) - .trim_start_matches('/') + let p = Path::new(p); + let rel = p.strip_prefix(repo).unwrap_or(p); + rel.components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/") }) .filter(|p| !p.is_empty() && !p.contains('\t')) - .unwrap_or("*"); + .unwrap_or_else(|| "*".to_owned()); let tool = tool.filter(|t| !t.contains('\t')).unwrap_or("?"); if let Ok(mut f) = std::fs::OpenOptions::new() .create(true)