Skip to content

feat(storage): reclaim the prepared-request captures already on disk - #4738

Open
Astro-Han wants to merge 4 commits into
apache:mainfrom
Astro-Han:feat/reclaim-retired-provider-request-captures
Open

feat(storage): reclaim the prepared-request captures already on disk#4738
Astro-Han wants to merge 4 commits into
apache:mainfrom
Astro-Han:feat/reclaim-retired-provider-request-captures

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

#4722 stops producing prepared-request captures. This reclaims the ones already on disk. On my own 814 MB installation they are 772.7 MB — 87% of the Artifact population, and 99.4% of their bytes are re-serialized duplicates of messages already in the ledger.

A bounded background sweep drains them through the same purge-intent path a Session delete uses, then stops. It reclaims the files and the Artifact rows naming them — nothing else. The per-segment detail #4722 stopped writing sits in the AgentRun ledger, which is append-only by design, so those rows stay where they are and only new calls are cheaper.

#4722 has landed, so this now stands on its own: 4 commits on current main.

Refs #4037, #4704.

🔀 What this changes about deletion

flowchart TB
    subgraph BEFORE["until now"]
        direction LR
        P1["purgeSessionArtifacts"] --> B1["Artifact bytes"]
        P1 --> E1["events naming them"]
    end
    subgraph AFTER["this sweep"]
        direction LR
        P2["purgeRetiredCaptures"] --> B2["capture bytes"]
        E2["attempts naming them<br/>append-only, cannot be retracted"]
    end
    BEFORE -.-> R["⇒ a capture reference<br/>must tolerate a missing referent"]
    AFTER --> R

    classDef gone fill:#fcebeb,stroke:#e24b4a,color:#a32d2d
    classDef live fill:#e1f5ee,stroke:#1d9e75,color:#0f6e56
    classDef rule fill:#fff6e0,stroke:#d99a1f,color:#8a5f00
    class B1,E1,B2 gone
    class E2 live
    class R rule
Loading

Artifact bytes and the events naming them have always been removed together. This sweep is the first mechanism that removes bytes while the events referencing them live on, because the AgentRun ledger is append-only: a historical ModelCallAttempt keeps its captureArtifactId forever.

That is the review question — who reads a capture reference, and does it survive the referent being gone.

  • captureArtifactId in a conversation copy was a required mapping. Once a capture was reclaimed, branching or copying any Session holding a historical model call threw Conversation copy is missing Artifact … and could never succeed again. The key now leaves with the bytes; the attempt still copies and still decodes.
  • Every other Artifact reference in conversation-copy.ts — attachment refs, tool-result Artifacts, snapshot lists, linked children — still throws on a missing target, and should. The sweep filters on source === 'provider_request_capture', and none of those name that source.
  • Decoding is untouched. captureArtifactId stays in the ModelCallAttempt schema and its validator: hasExactShape fails a whole record on an unknown key, so dropping the field would strand exactly the records this exists to free the bytes of.

Also removes the two capture decoders #4631 left behind in conversation-copy.ts when it retired both provider-request event types from the emitted catalogue — a copy no longer reaches either.

🧹 The sweep

starts after openedArtifactStore.recover() — a write authority refuses every mutation until it has recovered
batch 256 Artifacts per pass, through the shared mutation queue
pause max(250 ms, 3 × last batch) — a batch holds the writer lock and its cost rises with the store, so a fixed pause would not stay behind live turns
stops when nothing is left, when the Runtime Host closes, or after one failed batch (logged once)

Each batch is durable on its own, so stopping only means the next one does not start; a later run continues from the residue.

✅ Verification

  • format, lint clean. test:dist: @maka/storage 1,122, @maka/runtime 3,217, @maka/runtime-host 1,687, @maka/core 807 — 0 failures. typecheck also on @maka/desktop, @maka/ui, @maka/mcp, @maka/eval, maka-agent.
  • The copy test fails without the fix — verified by putting rewriteOwnedArtifactId back: Error: Conversation copy is missing Artifact artifact-gone.
  • Not run: full-repo suite, Playwright E2E. Nothing user-visible changes; the reclaimed Artifacts have had no reader since refactor: replace Headless with minimal Eval kernel #2605.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — traced the reference points, wrote the implementation and tests. Reviewed and verified by me.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

@github-actions github-actions Bot added the effort/XL Under 2500 readable lines label Sep 4, 2026
@Astro-Han
Astro-Han force-pushed the feat/reclaim-retired-provider-request-captures branch 3 times, most recently from d8b0213 to 2e08ddc Compare September 4, 2026 09:40
@Astro-Han
Astro-Han marked this pull request as ready for review September 4, 2026 09:40
@Astro-Han
Astro-Han requested review from ARE404, M4n5ter and likun666661 and removed request for ARE404 and M4n5ter September 4, 2026 10:12
Astro-Han added a commit that referenced this pull request Sep 4, 2026
Every model call stored a copy of the conversation. The prepared provider
request was serialized whole into a private Artifact, and the record beside it
carried up to 256 per-segment rows -- so one call cost tens of KB of database
plus a file that grew with the Session it belonged to. Nothing read either: the
capture's reader shipped in #1277 and was deleted in #2605, which kept the
producer, and the per-segment detail's only consumer folded it into four byte
totals.

Both producers are gone. The fold now runs where the request is dispatched, and
its result -- four byte totals plus a capped tool list, 1,971 B flat -- lands on
the canonical ModelCallAttempt, which already owns the request's facts. Per
model call with 60 tools and a 200-message conversation: database rows 46,077 to
1,971 B, capture file 136,967 B to none.

The Artifact store also sealed one snapshot per Session on every load and every
mutation, and a snapshot's revision hashes every record in its Session -- so 400
Sessions were sorted and hashed to answer a question about one. Every reader
reloads the whole store from the database first, so a kept snapshot never
survived to be read. Sealing one when a reader asks for it deletes the map, the
two methods that maintained it, and the per-mutation bookkeeping: one listPage
at 6,000 records goes 13.45 to 11.54 ms.

Compatibility: every decoder stays. `hasExactShape` fails a whole record on an
unknown key, so removing `captureArtifactId`, the `provider_request_capture`
source, or `PreparedRequestObservation` and its validator would strand exactly
the records this stops producing more of -- including their usage and cost.
Captures already written are left on disk; #4738 reclaims them.

One model-visible change: a sub-agent's spawn tool result listed the private
capture in `artifactIds` / `artifactCount`. A child turn now stores nothing of
its own, so that list is empty.

Also gives `graceful Host shutdown stops and drains an active Turn` the
checkpoint its sibling test already used, so the state its drain finds is not
left to how fast the machine is.

Closes #4082
Refs #4037
Refs #4704

Generated-by: Claude Code
Removing the capture sink stops the growth but leaves the residue, and the
residue is not the user's to clear: captures are `userVisible: false`, so no
UI lists them, and the only thing that ever deleted one was purging its
whole conversation. One workspace measured here holds 772 MB of them.

The store made them, so the store disposes of them. `purgeRetiredCaptures`
takes a bounded batch through the same mutation queue and purge-intent file
as every other deletion, and reports what is left; the sweep started at host
composition drains the rest behind live turns and stops when there is none.
A store that never held captures does one empty pass.

Interrupting it is safe by construction rather than by a checkpoint: each
batch is durable on its own and the next pass reads whatever remains, so a
crash, a close, or a stop all resume the same way.

`purge` now shares its body with the sweep instead of restating it.

Refs apache#4037

Generated-by: Claude Code
(cherry picked from commit 89628f2)
`captureArtifactId` was a required mapping, so once the sweep reclaimed a
capture Artifact, branching or copying any Session holding a historical
model call threw and could never succeed again. Every other Artifact
reference may keep throwing: the bytes and the events naming them have
always been removed together. This one cannot, because the sweep removes
bytes an append-only ledger still names — so the key now leaves with them
and the attempt keeps its record without the join.

Also drops the two capture decoders left behind by apache#4631, which retired
both provider-request event types from the emitted catalogue: a copy no
longer reaches either one.

Refs apache#4037

Generated-by: Claude Code
A write authority refuses every mutation until it has recovered, and the
sweep gives up after one failure. Starting it at composition meant its
first batch always landed before recovery ran, so it reclaimed nothing and
never retried. Both new tests recovered first and missed it.

Refs apache#4037

Generated-by: Claude Code
(cherry picked from commit d25a8ed)
A batch holds the writer lock and its cost rises with everything the store
holds, so a fixed 250 ms pause would not stay behind live turns on a store
big enough for the sweep to matter. The pause is now at least three times
the batch it follows.

Also drops a sweep assertion that could not fail: the batch size is a module
constant, so asserting it is a positive integer pinned nothing.

Generated-by: Claude Code
@Astro-Han
Astro-Han force-pushed the feat/reclaim-retired-provider-request-captures branch from 2e08ddc to 70fe70b Compare September 4, 2026 10:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Under 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant