You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Record leases from the pre-tool hook, three CLI paper cuts, and the fork/exclude finding (#21)
* 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,
<repo>/.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.
* Say what went wrong, cap `turns`, and spell out how `exclude` matches
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.
* Record that `exclude` does not govern what a fork writes
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.
* 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().
* 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 '/'.
Copy file name to clipboardExpand all lines: README.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -74,6 +74,8 @@ Not yet covered: an `install` writer for Codex's MCP config (TOML), Kimi Code CL
74
74
75
75
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).
76
76
77
+
`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.
78
+
77
79
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).
- 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.
0 commit comments