Skip to content

fix(events): keep the event log bounded during long turns - #401

Open
mossaab wants to merge 6 commits into
co-l:developfrom
mossaab:fix/event-log-saturation
Open

mossaab wants to merge 6 commits into
co-l:developfrom
mossaab:fix/event-log-saturation

Conversation

@mossaab

@mossaab mossaab commented Oct 1, 2026 •

Copy link
Copy Markdown

Summary

Fixes #400.

A turn is a single runTopLevelAgentLoop call that can run for hours, so the end-of-turn snapshot — and the
cleanupOldEvents() that runs right after it — never ran while the turn was alive. The event log grew
without bound while every state load re-read it: on the reporter's instance one session reached 320k
events / 909 MB of payload behind a 95 KB snapshot
, costing ~12 s of synchronous better-sqlite3 work
several times a minute. That pinned the main thread at 98% of a core for 9.5 h and starved the HTTP/WS loop
until the UI could no longer stop the session.

Bounded writes

  • Snapshot on a byte (48 MB) / wall-clock (10 min, with a 4 MB content floor) cadence during the turn,
    plus end-of-turn and abort, pruning what the snapshot absorbed. Snapshots are only taken at a quiescent
    point (no message streaming, no tool call awaiting its result) — the context fold drops later events for a
    messageId already in the snapshot. Cadence state is per session and shared with workflow steps and
    sub-agents, which append to the same table.
  • Streamed argument-carrying tool.preparing events are coalesced to one per 250 ms per tool call. They
    re-send the whole accumulated argument JSON, which produced 146k events for 691 real calls.

Bounded reads

  • The context-window fold reads only the four event types that can change the window id.
  • The conversation build replays the latest snapshot instead of the raw log.
  • The user-message count is a SQL COUNT(*) instead of materializing the session.
  • The snapshot builder folds the previous snapshot plus the tail, never the raw log.

Store

  • Adds the covering index (session_id, event_type, seq) and drops the two superseded ones. The snapshot
    lookup walked the whole session backwards, fetching each row to test event_type, on every state load.
  • Stops evicting the parsed snapshot cache on non-snapshot appends — during streaming the cache was
    permanently cold.

Observability

  • Warns when a state load exceeds 1 s (throttled process-wide) or when the session database exceeds 300 MB
    at startup. The incident produced no log line at all.

Verified

Unit suite 443 files / 5,982 tests pass (18 new); tsc --noEmit, ESLint and Prettier clean.

Measured on a copy of the reporter's 1.4 GB production database (74,519-event session), warm page cache,
median of 5 runs:

operation before after
getLatestSnapshot() 30.1 ms 0.2 ms
getContextWindowEvents() 25.6 ms 0.3 ms

EXPLAIN QUERY PLAN confirms the change, from SEARCH events USING INDEX idx_events_session_seq (session_id=?) to SEARCH events USING INDEX idx_events_session_type_seq (session_id=? AND event_type=?).

The build was then run against that live database. The mid-turn cadence was confirmed by event ordering —
the snapshot precedes the running.changed: false, so it cannot be an end-of-turn snapshot:

seq= 53863  turn.snapshot
seq= 53864  running.changed  isRunning=false

The prune that followed collapsed 35,396 visible events to 117. On the same run: tool.preparing was 1.0
per real tool call (was 213), page-cache reads ~2 KB/s (was ~170 MB/s), CPU 3.1% (was 98%), and the
database payload went from 1,258 MB to 391 MB.

Known limitations

AI-Enhanced Development

  • AI Models: DeepSeek Flash (via pi coding agent)

Cache Impact

  • No

The reconstructed message list is unchanged — only the read path that produces it is bounded. The
server-side snapshot/prompts caches are affected (invalidateSessionCache now keeps the parsed snapshot
entry when the appended event is not a turn.snapshot), but that is an in-process cache, not the LLM prompt
cache.

A turn is a single runTopLevelAgentLoop call that can run for hours, so the
end-of-turn snapshot — and the cleanup that runs right after it — never ran.
The log grew without bound while every state load re-read it: the incident
session reached 320k events / 909 MB of payload behind a 95 KB snapshot, and a
single state load cost ~12 s of synchronous better-sqlite3 work several times a
minute, pinning the main thread at 98% of a core for 9h30 and starving the
HTTP/WS loop until the UI could no longer stop the session.

Bounded writes:
- snapshot on a byte (48 MB) / time (10 min) cadence during the turn, at the end
  of every workflow step and inside sub-agent loops, pruning what the snapshot
  absorbed. Snapshots only happen at a quiescent point (no message streaming, no
  tool call awaiting its result) — the context fold drops later events for a
  messageId already in the snapshot.
- throttle streamed tool.preparing events to one per 250 ms per tool call; they
  re-send the accumulated argument JSON, which produced 146k events for 691
  real calls.

Bounded reads:
- the context-window fold reads only the four event types that can change the
  window id (6.9 s -> 1.4 ms on the incident session)
- the conversation build replays the latest snapshot instead of the raw log
- the user-message count is a SQL COUNT instead of a full materialization
- the snapshot builder folds the previous snapshot plus the tail

Store:
- add the covering index (session_id, event_type, seq) and drop the two
  redundant ones; the snapshot lookup walked 320k index entries per call
  (105 ms -> <1 ms)
- stop evicting the parsed snapshot cache on non-snapshot appends

Observability:
- warn when a state load exceeds 1 s (throttled) or when the database exceeds
  300 MB at startup; the incident produced no log line at all.
Copilot AI balanced review requested due to automatic review settings October 1, 2026 12:56
@github-actions github-actions Bot added the bug Something isn't working label Oct 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Nested cadence state can snapshot unresolved parent tool calls, while message counting and byte accounting are inaccurate after pruning.

Review effort: Balanced
Findings: 1 High severity · 2 Medium severity

Open (3)
What changed in this PR

Bounds event-log growth and improves session-state read performance during long-running turns.

Changes:

  • Adds periodic snapshotting, pruning, and streamed-event throttling.
  • Optimizes event queries, indexes, caching, and observability.
  • Expands regression coverage and debugging documentation.
File Description
src/​server/​workflows/​executor.ts Adds workflow snapshot cadence.
src/​server/​workflows/​executor-subgroup.test.ts Updates event-store mocks.
src/​server/​workflows/​executor-retry.test.ts Updates retry test mocks.
src/​server/​workflows/​executor-resume-nudge.test.ts Updates resume test mocks.
src/​server/​workflows/​executor-mode.test.ts Updates mode test mocks.
src/​server/​utils/​session-utils.ts Moves message counting into SQL.
src/​server/​sub-agents/​manager.ts Adds sub-agent snapshot cadence.
src/​server/​queue/​processor.test.ts Mocks SQL message counting.
src/​server/​events/​store.ts Optimizes queries, indexes, caches, and diagnostics.
src/​server/​events/​store.test.ts Tests storage optimizations.
src/​server/​events/​session.ts Adds bounded context-window reads.
src/​server/​events/​session.test.ts Tests filtered context reads.
src/​server/​db/​migrations.test.ts Tests index migration.
src/​server/​db/​index.ts Uses shared event-index DDL.
src/​server/​db/​events-schema.ts Defines shared event indexes.
src/​server/​chat/​system-reminder.test.ts Updates event-store mocks.
src/​server/​chat/​stream-pure.ts Throttles preparing events.
src/​server/​chat/​stream-pure.test.ts Tests preparing-event throttling.
src/​server/​chat/​snapshot-cadence.ts Implements snapshot cadence and pruning.
src/​server/​chat/​snapshot-cadence.test.ts Tests cadence behavior.
src/​server/​chat/​orchestrator.ts Integrates cadence into chat turns.
src/​server/​chat/​orchestrator.test.ts Updates orchestrator mocks.
src/​server/​chat/​conversation-history.ts Replays snapshots plus event tails.
src/​server/​chat/​agent-loop.test.ts Updates context-read mocks.
src/​server/​chat/​agent-loop-pause.test.ts Updates pause-test mocks.
docs/​SESSION-DEBUGGING.md Documents the covering index.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +107 to +111
// Anything already in flight at this point predates this cadence (a parent
// turn's message, a tool the caller is waiting on) — only new opens block.
const atRest = eventStore.getEventLogTail(sessionId, state.lastSnapshotSeq)
state.openMessagesAtRest = atRest.openMessages
state.pendingToolCallsAtRest = atRest.pendingToolCalls
Comment thread src/server/events/store.ts Outdated
const row = this.db
.prepare(
`SELECT COUNT(*) AS events,
COALESCE(SUM(LENGTH(payload)), 0) AS bytes,
Comment thread src/server/events/store.ts Outdated
Comment on lines +652 to +655
`SELECT COUNT(*) AS n FROM events
WHERE session_id = ? AND event_type = 'message.start'
AND json_extract(payload, '$.role') = 'user'
AND COALESCE(json_extract(payload, '$.isSystemGenerated'), 0) = 0`,
@mossaab

mossaab commented Oct 1, 2026

Copy link
Copy Markdown
Author

Hi @co-l — first PR here. The PR Checks workflow is sitting in action_required, waiting on a maintainer to approve the run, so lint / typecheck / test haven't executed yet. Could you approve it?

For context: #400 documents the incident this fixes, and I've already built and run this branch against my production database — the mid-turn snapshot cadence is confirmed there (details in the PR body). Happy to change anything you'd rather see done differently.

Three issues raised in review of co-l#401, all verified against the code.

Byte accounting: LENGTH() on a TEXT value counts characters, not bytes, so a
non-ASCII payload (accents, CJK, emoji) understated the un-snapshotted tail by
up to 3x — measured 1.15x for accented French, 3.00x for CJK — and let the 48 MB
cadence budget overshoot before a snapshot was taken. Measure the BLOB
representation instead.

User-message count: real user messages live in the latest snapshot as well as in
the raw rows, because cleanupOldEvents() does not whitelist message.start (unlike
mode.changed or context.state) and therefore deletes the rows once a snapshot
absorbs them. Counting only the raw rows collapsed the count to ~0 after every
prune, re-arming the "is this the first user message?" condition that drives
session naming. Count the snapshot's messages plus the rows after its seq, and
restore the tombstone exclusion the previous getEvents()-based implementation had.

Mid-turn tool results: a mid-turn snapshot can capture a tool call whose result
has not been emitted yet — a sub-agent's cadence fires while its parent turn is
still awaiting that call (the parent's in-flight call became the nested cadence's
baseline, so it does not defer the snapshot), and cleanupOldEvents() then deletes
the parent's tool.call row. buildContextMessagesFromEventHistory() dropped the
parent's later tool.result because its messageId was already in the snapshot,
which left the call unfulfilled; stripOrphanedToolCalls() then removed the call
from the message entirely, silently losing the sub-agent's return value from the
parent's context. Keep an event that completes the snapshot rather than
duplicating it.

Regression tests added for all three.
@mossaab

mossaab commented Oct 1, 2026

Copy link
Copy Markdown
Author

Thanks for the review — all three findings were valid, and I verified each against the code before changing anything.

Nested cadence pruning the parent's tool call (High) — confirmed. createSnapshotCadence() rebaselines pendingToolCallsAtRest on every nested factory call, so a sub-agent's cadence sees the parent's in-flight call as pre-existing residue, treats the log as quiescent, snapshots, and cleanupOldEvents() then deletes the parent's tool.call row. buildContextMessagesFromEventHistory() drops the parent's later tool.result because its messageId is already in the snapshot, which leaves the call unfulfilled, and stripOrphanedToolCalls() removes it from the message entirely — silently losing the sub-agent's return value from the parent's context.

Fixed on the replay side: an event that completes the snapshot (a tool.result whose toolCallId is unresolved in it) is now kept. I chose that over the suggested cadence-side guard because deferring snapshots for the whole duration of an in-flight call would restore unbounded growth for long sub-agent runs — the parent awaits the sub-agent, so its call stays unresolved throughout.

UTF-8 byte length (Medium) — confirmed empirically. LENGTH() on a TEXT value counts characters, not bytes: 1.15x for accented French, 1.35x for emoji, 3.00x for CJK, so the 48 MB budget overshot by that factor. Now measures the BLOB representation.

Snapshot-embedded user messages (Medium) — confirmed. cleanupOldEvents() does not whitelist message.start (unlike mode.changed or context.state), so those rows are deleted once a snapshot absorbs them and the count collapsed to ~0 after every prune — re-arming the messageCount > 1 guard in needsNameGeneration(). Now counts the latest snapshot's messages plus the rows after its seq, and restores the tombstone exclusion the previous getEvents()-based implementation had.

Regression tests added for all three. Full suite: 443 files / 5,986 tests passing, tsc --noEmit, ESLint and Prettier clean.

@vv7r

vv7r commented Oct 4, 2026

Copy link
Copy Markdown

Hi @mossaab, thanks for this PR. I've been running it with a local model (llama.cpp, Qwen3-based) on long autonomous turns with sub-agents, and found four issues: one makes the database grow without bound, and in the other three a long sub-agent run never gets a mid-turn snapshot, or loses its context once one is taken. Sharing them here since they're in this PR's code. I have fixes with tests for all four, and can push them on top of your branch, open a follow-up PR once this one is merged, or just leave them to you, whichever you and @co-l prefer.

1. Every snapshot doubles the compaction history (most severe)

takeSnapshot() builds the next snapshot from combineEventsWithSnapshot(snapshot, tail). Since #373 that stream also replays the previous snapshot's contextWindows and formatRetries as synthetic context.compacted / pattern.retry events, just before the snapshot event, and the fold then seeds its history from the snapshot on top of them. So each snapshot doubles the history, and the cadence snapshots often. On my instance, after an afternoon of normal use: 7 compactions stored as 2.3 million entries, a 433 MB snapshot rewritten at every turn end (~8 s per write, so a prompt took that long to show up in the chat), then SQLITE_FULL once the disk filled.

Fix: when the fold seeds from a snapshot, keep one entry per compaction / retry (same windows or attempt, same timestamp). This also shrinks a snapshot already inflated this way at the next snapshot (here 433 MB → 2.7 MB; the DB went from 625 MB to 5 MB after a VACUUM).

2. Every compaction leaves one message "open" in the tail count

A successful compaction appends message.start a second time for the summary attempt's messageId (with the summary as content), then a single message.done. getEventLogTail() counts starts minus dones, so each compaction adds +1 to openMessages for good. After the first compaction of a turn, the cadence finds the turn busy at every check and never snapshots again until the turn ends, in the main turn or in a sub-agent.

Fix: count distinct messageIds. I wrote the counts as subqueries on (session_id, event_type, seq), so they only read their own event types; on a 46 MB tail the check went from ~190 ms to ~140 ms.

3. A deferred check waits a full period again

When a check finds the tail over budget but the turn busy, it still resets lastCheckAt. A sub-agent is nearly always streaming or running a tool, so the next check 30 s later usually lands on a busy moment too. Live: 7 and 12 minute sub-agent runs, tail over budget, no mid-turn snapshot.

Fix: once deferred, the next quiet point (message.done / tool.result) checks again, at most every 5 s, since parallel tools or sub-agents can keep the turn busy over many quiet points.

4. The sub-agent's context ignores the snapshot

buildContextMessagesFromEventHistory() expands the latest snapshot, but buildSubAgentContextMessages() reads raw events only. A snapshot taken while a sub-agent runs absorbs (and prunes) its earlier messages, so its next request has lost its task and its work; Qwen3 templates then answer HTTP 500 "No user query found in messages". This only shows up once 2 and 3 are fixed. Fix: share the snapshot expansion between both folds, and treat a replayed sub-agent summary message as its compaction boundary. This one builds on a compaction fix I'm about to send separately (it keeps a sub-agent's task in its context after a compaction), so it would come as a follow-up.

Verified live with fixes 2–4 and OPENFOX_SNAPSHOT_MAX_BYTES=1000000 to force it: a 9-minute sub-agent run with 4 compactions, a mid-turn snapshot at 259 s pruning ~3,150 events, and the sub-agent finishing its 12 steps with the right output and no errors. Before: no mid-turn snapshot at all.

@mossaab

mossaab commented Oct 4, 2026 •

Copy link
Copy Markdown
Author

Thanks — this is a much more useful review than the PR deserves. Running it with a local model on long autonomous turns with sub-agents is the configuration I couldn't test. Four bugs with tests is more work than the change itself.

Yes to option 1: please push 1–3 on top of my branch.

Fixes 2 and 3 aren't optional for me — they're what makes the cadence fire at all. Your own numbers make the point: 7 and 12 minute sub-agent runs, tail over budget, no mid-turn snapshot. Landing it without them means landing something that often doesn't fire. # 4 as the follow-up you suggested makes sense.

One framing point on # 1, because it changes who owns the fix. You place the replay at #373, which matches the base I have installed: I see the synthetic events being pushed ahead of the snapshot, and the fold reseeding from turn.snapshot on top of them. So the doubling is pre-existing, and the cadence takes it from occasional to per-turn. Worth doing as its own fix against the base and landing first — it shrinks snapshots that are already inflated, independent of this PR.

One implementation note for 1 and 4: the replayed events are stubs, and for # 4 the stub is missing more than the summary. A replayed context.compacted carries the window ids and timestamp but summary is empty, and no subAgentId at all — so it is indistinguishable from a main-turn compaction for anything filtering on that field. A replayed pattern.retry keeps only attempt and maxAttempts. Which I think is why your dedupe key is (windows or attempt, timestamp): those are the only fields that survive. For # 4 it means the boundary has to come from the snapshot's messages rather than from the replayed event.

A gap on my side: I can't reproduce # 1. 92% of my database is turn.snapshot payload (630 MB of 684 MB), but the large snapshots are large because of message count, not duplicated history — I find zero
duplicated entries in contextWindows. Could you share your repro conditions? I'd rather validate the fix against a case I can actually trigger.

Two questions:

  1. 2 — does "count distinct messageIds" hold if two different messageIds have mismatched starts and dones? A set-based "started but not done" seems safer unless that can't happen.
  2. 3 — 140 ms per check on a 46 MB tail, every 5 s once deferred. What does that look like at 400 MB, and which index is the count using?

Happy with whichever you and @co-l prefer — my preference is 1–3 here, then # 4 once your sub-agent compaction fix lands.

@vv7r

vv7r commented Oct 4, 2026

Copy link
Copy Markdown

Thanks, and good questions. Short answers first, details below.

#1: the doubling needs this PR, it doesn't happen on the base alone. You're right that both halves exist on develop: combineEventsWithSnapshot() pushes the stubs ahead of the snapshot, and the fold reseeds from turn.snapshot. But on develop nothing feeds that combined stream into a snapshot. The end-of-turn snapshot is built from eventStore.getEvents(sessionId) (raw rows: the previous snapshot plus the events since, no stubs), so the history is counted once. On this branch, takeSnapshot() builds every snapshot (mid-turn and the end-of-turn flush()) from combineEventsWithSnapshot(snapshot, events), and that's where it doubles.

I ran the same test on both: one context.compacted, then four snapshots.

  • develop's end-of-turn path: 1 → 1 → 1 → 1 entries
  • this branch at a662baed (cadence.flush() four times): 1 → 2 → 4 → 8

That probably explains why you see zero duplicates: the snapshots in your DB were written by the base, or by sessions with no compaction since you installed this branch. To reproduce: run a662baed, get one compaction in a session (a small context window, or /compact), then send a few more messages. Each turn end doubles it:

SELECT seq, json_array_length(json_extract(payload, '$.contextWindows')) AS entries
FROM events WHERE session_id = ? AND event_type = 'turn.snapshot' ORDER BY seq;

So I'd keep it in this PR, as its own commit before the cadence ones. The dedupe also shrinks snapshots that are already inflated, which matters for anyone who has run this branch.

Stubs: agreed, that's exactly why the key is (windows or attempt, timestamp), since nothing else survives. And for #4, the boundary does come from the snapshot's messages: the fix treats a replayed sub-agent summary message as the boundary, not the stub event.

Q1, distinct messageIds: you're right, a set is safer. Counting distinct starts minus distinct dones lets a done whose start is before fromSeq hide a different open message. I'll switch to "started in the tail and not done in the tail" (messageId NOT IN (done ids)). It goes through the same covering index: 3 ms on a 48 MB tail, 54 ms on 400 MB.

Q2, cost at 400 MB: measured on a synthetic tail (assistant messages with deltas and tool rounds, ~3.5 KB payloads):

tail events getEventLogTail
48 MB (default budget) 13k 66 ms
100 MB 28k 91 ms
400 MB 111k 419 ms

The four counts use idx_events_session_type_seq (session_id, event_type, seq) as a covering index (EXPLAIN QUERY PLAN: SEARCH events USING COVERING INDEX), so they're cheap. The cost is COUNT(*) + SUM(LENGTH(CAST(payload AS BLOB))), which reads every tail row's payload through (session_id, seq). It's linear in the tail size. In practice the 5 s re-check only runs while the tail is over budget and the turn is busy, and the snapshot fires at the next quiet point, so the tail stays close to 48 MB. 400 MB would mean the cadence never fires, which is the bug that 2 and 3 fix. If you'd rather make it O(1), the cadence already sees every event it appends, so it could keep the byte count and the open sets in memory and only query on startup. Happy to do that instead if you prefer.

Next step: I can't push to your fork directly, so I'll open a PR against mossaab:fix/event-log-saturation with three commits (the dedupe first, then 2 with the set-based count, then 3). Then #4 follows once the compaction fix lands.

@mossaab

mossaab commented Oct 4, 2026

Copy link
Copy Markdown
Author

Thanks — and you're right, I had # 1 wrong. I argued the doubling was pre-existing because both halves are on develop, but I never checked whether anything there feeds the combined stream into a snapshot. takeSnapshot() is what does it: getEventsSinceSnapshot → combineEventsWithSnapshot(sessionId, snapshot, events) → buildSnapshotFromSessionState. On develop the end-of-turn snapshot comes from the raw rows, so the stubs never reach a snapshot build. And that explains my zero-duplicate measurement: those snapshots came from the base, or from sessions with no compaction since I installed the branch — exactly as you said.

So # 1 stays here, as its own commit before the cadence ones. Your 1 → 1 → 1 → 1 versus 1 → 2 → 4 → 8 settles it, and I'll run your query on my side to confirm before merging.

Both answers land, and the set-based count being faster as well as correct is a nice result — I'll take it.

On the O(1) offer, I'd rather keep the query. An in-memory counter is only correct if the cadence observes every mutation of the tail, and it doesn't: updateEventPayload changes a payload's length without touching seq (e.g., vision fallback enrichment), and tombstoneEvents / deleteEventsAfterSeq change what counts as the tail. Any drift silently disables the cadence, which is issue 2's failure mode again. 66 ms per check at the default budget isn't the bottleneck — the 5 s re-check only runs while the tail is over budget and the turn is busy, so in practice the tail stays near 48 MB.

Yes please to the PR against mossaab:fix/event-log-saturation, with the three commits in that order. I'll review the dedupe commit first.

vv7r and others added 3 commits October 4, 2026 23:17
A snapshot is built from the previous one plus the events since
(combineEventsWithSnapshot). That stream also replays the previous
snapshot's compactions and format retries as synthetic events, just
before it, for the consumers that count them; the fold then seeded its
history from the snapshot on top of them, so each snapshot doubled the
history and every later one doubled it again. With the mid-turn cadence
snapshotting often, it exploded: seen live, 7 compactions stored as 2.3
million entries in a 433 MB snapshot, rewritten at every turn end (8 s
per write, a prompt took that long to show) until the disk was full.

The fold keeps one entry per compaction or retry (same windows or
attempt, same timestamp) when it seeds from a snapshot. This also
shrinks a snapshot already inflated that way on the next snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A successful compaction starts its summary attempt's message again with
the summary as content, then closes it once. The tail counted starts
minus dones, so every compaction left one message open for good: the
snapshot cadence then found the turn busy at every check and never took
a mid-turn snapshot for the rest of the turn (main turn or sub-agent).

The tail now counts the ids started and not done in it (and tool calls
without a result), instead of subtracting counts: a message started
twice counts once, and a done or result whose start is before the tail
can no longer hide another open message or pending call. The counts read
only their own event types through the session/type/seq index; the
check measured 30 ms on a 48 MB tail (the default budget), 64 ms on
100 MB, 269 ms on 400 MB, dominated by summing the tail's payload bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cadence measures the tail at most every 30 s, on whatever event comes
in, and only snapshots at a quiet point (no message streaming, no tool
round in flight). A deferred check still restarted the 30 s period, and a
sub-agent is nearly always streaming or running a tool: the check kept
landing on a busy moment and the snapshot was put off to the end of the
turn. Seen live on 7 and 12 minute sub-agent runs, the tail over its
budget and no mid-turn snapshot (together with the open-message miscount
fixed in the next commit).

A check that finds the tail over budget while the turn is busy now marks
the cadence deferred, and the next quiet point (a message.done or a
tool.result) checks again without waiting for the period, at most every
5 s: each check scans the tail (~0.1 s at 46 MB), and parallel tools or
sub-agents can keep the turn busy over many quiet points in a row. The
budget is evaluated before the in-flight test, so only an over-budget tail
defers.

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

vv7r commented Oct 4, 2026

Copy link
Copy Markdown

Opened mossaab#1 against your branch with fixes 1–3 (one commit each, with tests). Fix 2 now counts the ids started and not done, as you suggested; I applied the same to pending tool calls, which had the same subtraction. #4 will follow after the compaction fix.

@vv7r

vv7r commented Oct 4, 2026

Copy link
Copy Markdown

One correction on my side: the set-based count isn't faster, I compared the wrong numbers. The 3 ms was the open-message subquery alone, the 66 ms the whole tail check. Measured end to end, the check costs about the same either way (30 ms at 48 MB, 269 ms at 400 MB on an idle machine, as in the PR description): it's more correct, not faster. Agreed on keeping the query rather than in-memory counters, for the reasons you give.

`x NOT IN (subquery)` is never true when the subquery returns a NULL: a
single message.done or tool.result without an id in the tail would read
openMessages and pendingToolCalls as 0, and the cadence would snapshot
mid-message. The subqueries now skip NULL ids.

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

mossaab commented Oct 6, 2026

Copy link
Copy Markdown
Author

Update: the follow-ups from @vv7r are in — 4 commits, fast-forwarded onto this branch (no merge commit), tip 72d614eb:

  • keep one history entry per compaction across snapshots — the doubling (takeSnapshot() feeding combineEventsWithSnapshot() into the fold; his instance: 7 compactions stored as 2.3M entries, a 433 MB snapshot rewritten at every turn end). The dedupe also shrinks a snapshot already inflated this way.
  • count open messages / pending tool calls as sets, not starts-minus-dones (a compaction starts its summary message twice and closes it once, which left one message open for good and stopped the cadence from ever firing again in that turn).
  • re-check at the next quiet point once a check was deferred, at most every 5 s (a sub-agent is nearly always busy, so the next 30 s check used to land on a busy moment too).
  • skip NULL ids in those counts (the NOT IN (…, NULL) fragility I raised in review).

Items 2 and 3 are what make the cadence actually fire during a long sub-agent run, so this branch is materially different from the one you last saw — worth a re-read rather than a look at the appended commits only.

Heads-up: the PR Checks workflow is still sitting in action_required, so nothing has run in CI here — the numbers above come from my machine. The last of his four findings (a sub-agent's context ignores the snapshot, once the cadence fires) is a separate follow-up, tracked in mossaab#2; it depends on a sub-agent compaction fix he is sending on its own.

@rankaiyx

rankaiyx commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

I had Qwen3.8-27B independently review it, and the implementation is solid. I am currently conducting real-world testing.

@rankaiyx

rankaiyx commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

It works well.
LGTM

@rankaiyx

Copy link
Copy Markdown
Contributor

The task I am testing has currently completed 16 rounds of compression and is working perfectly.
#432

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Long-running session pins the server at ~100% CPU: unbounded event log + full-scan reads on every session-state load

4 participants