Skip to content

Save the work folder at pauses, not after every tool batch - #2356

Draft
AbirAbbas wants to merge 9 commits into
app/v3from
perf/save-at-pauses
Draft

AbirAbbas wants to merge 9 commits into
app/v3from
perf/save-at-pauses

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Why

The desktop app spent a lot of CPU while agents worked. In the app, every finished tool batch ended in a full-workspace capture. A shell call's changed paths are unknown, so each capture made the saving daemon (furrow-<ver> serve) stat every tracked file and all of .git. codeaf app also listed ignored files with about 20 git calls per capture.

Measured on an M4 Pro (desk main + this engine, Ollama, side-by-side app with a throwaway home):

before (app/v3 0f8574e) after (this branch)
saving daemon, CPU-s per shell call 0.51 (big checkout), 0.55 (small) 0.11, 0.17
saves per turn one per call + turn end (6 for 4 calls) 30 s cadence + turn end (3 for 11 calls; 1 for 4)
11 short calls over 66 s ~5.5 CPU-s, 12 saves (estimated) 1.23 CPU-s, 2 saves
idle app (shell / webview / engine) ~2.5% / ~6% / 0.2% of a core unchanged

The UI's rewind only reaches the start of the person's turns, and it leaves files as they are. File seals serve moving, taking over and recovering a chat. The WAL already makes every finished call durable before any capture.

What changes

  • executor.SettleStep marks the turn loop's per-batch settle (internal/session/loop.go). Every other Settle caller still forces a capture.
  • In pipeline mode, a step settle starts no capture while an unknown-effect call is pending and the oldest unsaved call is under 30 s old (saveInterval). Exact-path-only and transcript-only steps capture as before. Turn end, close, exit (Flush) and other forced settles capture everything pending.
  • Boundaries that read the head (Captured/Await and their callers: take-over checks, rewind, upload, reacquire, displacement) force held calls into a capture between steps only. During an open step they keep today's behaviour and never wait on a running tool. A seal taken mid-step would carry a call's work without its result line. The per-command ownership check (CheckLease) only joins a running capture (WaitCapture).
  • Displacement settles held calls before choosing the head to keep (Batcher.BeforeResolve).
  • Needs a careful review (relay ownership):
    • stamping.Seal now lets finished calls seal locally after the hold is lost, so displacement keeps them in the branch. New tools and bookkeeping-only seals stay refused.
    • Batcher.Displaced now always joins lost(), so CheckLease waits for a branch a background heartbeat or follower is still writing. That race was latent; the longer pre-branch save exposed it.

Tests

New contract tests: TestSavePauses* in cellstore, cellsync, syncsetup, executor and session. They use an injected clock and a counting fake store. The contract:

  • a young step holds;
  • a 30 s-old step captures;
  • 5 one-call batches, each with a step settle, produce one seal at turn end;
  • a boundary between steps captures, and during a step it doesn't;
  • a crash leaves held calls in the WAL;
  • displacement keeps held calls in the branch.

Status:

Open points

  • One ~1.9 CPU-s codeaf app burst was seen once, at a turn-end settle of 11 held calls in a large checkout. It did not recur in three later runs (two repeats with an unstripped build and sampling, plus a 66 s turn with a mid-turn save). Treated as a one-off.
  • Follow-up in furrow: a capture still stats all of .git (immutable packs included). Fewer captures make that rarer, but each one still costs the same.
  • Will rebase onto the latest app/v3 (which now has Moving a chat says how far it has got, stops when stalled, and can be cancelled #2348, moving a chat) and re-run the focused tests before marking ready.

🤖 Generated with Claude Code

AbirAbbas and others added 4 commits November 4, 2026 12:18
SettleStep marks the context of the settle the turn loop takes after each
tool batch, so a recording seat can choose its own saving cadence there while
every other Settle caller keeps forcing its boundary. The marker rides the
existing Settle path, so the seat wrappers need no new interface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every finished shell call reached a full-workspace capture at the end of its
batch: the saving daemon stats every tracked file and all of .git, and the
engine lists ignored files with ~20 git calls. Measured on an M4 Pro that is
~0.5 CPU-s in the daemon plus ~0.2 in codeaf app per command, a quarter of a
core for an agent running a command every two seconds.

A step settle now holds a batch with unknown effects in the WAL until the
oldest unsaved call is 30 s old. Turn end, close, exit and every other forced
settle still capture everything pending. Boundaries that read the head
(take-over checks, rewind, process exit) force held calls into a capture
between steps and never wait on an open step's tool. The WAL already makes
each finished call durable, so a death before the save loses nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ause

Publishing a local chat, reacquiring an expired hold and choosing the head to
keep on displacement now settle held calls first, so another computer never
gets a head that misses finished work. The ordinary per-command ownership
check only joins a running capture. After the hold is lost, finished calls
may still seal locally so displacement keeps them in the branch; new tools
and bookkeeping-only seals stay refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Displaced returned at once when the hold was already stale, but a background
heartbeat or follower sets stale before it finishes saving and branching the
kept work. CheckLease could then return before the branch map was written.
Displaced now joins lost under flushMu, whose own checks still prevent a
second resolution, so the caller only returns once the kept branch exists.
The window grew once displacement settles held calls before branching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas force-pushed the perf/save-at-pauses branch from 9cac8c2 to 0868e9a Compare November 4, 2026 17:18
AbirAbbas and others added 5 commits November 4, 2026 12:57
…s quiet

A setup turn's calls run deferred like any other turn-loop call, so the
step settle now forces their capture instead of applying the save interval.

A viewer's store refuses seals; that refusal no longer becomes the
recorder's error, so take-back reaches its own WAL recovery as on app/v3.
Drain leaves the held registry when the recorder closes, Unsaved reports
how many calls a held recorder still owes, and take-over checks keep
reporting completed WAL calls as local work when their capture fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
If the save before branching fails, displacement no longer stays pending
with nothing to retry it. It branches the turns already noted, and the
unsaved calls stay completed in the local WAL, where take-over checks and
take-back already keep them, as they do for an open step's calls. The
notice then names only the turns it kept rather than saying every unsaved
edit was kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…record

The save-at-pauses section said "call log" five times, which pulled the
manual's "turn on logging" search toward sessions-and-rewind. No question
the manual is tested on uses "ledger".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant