fix(mothership): re-sync Chat reconnects from the worker log and trim the replay ring by bytes - #8469
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
There was a problem hiding this comment.
All reported issues were addressed across 37 files
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
|
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 37 files
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Fix all with cubic | Re-trigger cubic
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 38 files
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Fix all with cubic | Re-trigger cubic
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
No issues found across 38 files
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
Adds StreamReplayRequest and StreamReplayEnd from the worker's contracts (bun run contracts:sync).
…m the worker log A reconnect whose cursor fell behind the ring, a fresh tab reading a ring that lost its head, and a cursor ahead of a ring whose numbering restarted now stream the run from the worker's read-only replay instead of ending the turn with replay_gap or replaying a partial response. - The reconnect route opens POST /api/streams/replay with no receipt and forwards it under its own cursors from 1, with x-mothership-stream-replay: log so the client rebuilds the turn from an empty response. A response on the replay is never handed back to the ring, which shares no position with the log. - A parked replay holds the response open until the run resumes; a capped, stalled, or cut replay ends it without a terminal so the client re-attaches. - A batch read the ring cannot serve returns no ring events. - A live tail whose ring restarts under it ends without a terminal, so the client re-attaches and is re-synced. - An unknown run keeps the replay_gap terminal; an unreachable worker answers 503 so the client retries.
…nstead of refusing The append script pruned the oldest events by count only, so any stream averaging more than ~335 B per event reached the 32 MiB owner budget before 100k events and the refusal ended the turn. The ring now also trims its oldest events until the retained bytes fit three quarters of the owner ceiling, refunding exactly what it drops in the same script. A byte trim never drops a member the write adds, so an inflated counter still refuses rather than silently discarding the new frame.
…its head A byte trim advances the ring past seq 1, and three callers read it from seq 0 assuming the head was there: stream recovery rebuilt a controller's context from the tail, and both chat snapshot routes painted a truncated turn. One predicate, startsAtReplayHead, now guards them and the reconnect route's gap check: - recovery refuses with StreamReplayHeadTrimmedError instead of persisting a truncated turn; - the snapshot routes skip the snapshot, so the client reconnects; - the reconnect route re-syncs the view from the worker's log (stream) or serves no tail events (batch). When recovery was refused, a parked or stalled replay ends the view with recovery_unavailable instead of re-attaching forever. The append script also trims a replayed member that lands below the ring for bytes, keeping the tail contiguous, and caps byte trimming at 4096 members per append so an oversized ring catches up over several appends. The budget docs now say the user counter bounds bytes held, not bytes written per hour.
…ep log readers on the log - Recovery no longer refuses a run whose ring lost its head, which orphaned long runs after a Sim deploy. It treats that ring like an expired one: the new controller starts from an empty context at the ring's latest seq, and re-attaches with an empty receipt. The worker then re-sends the whole response and re-hands its parked calls. Usage stays with the worker's per-run settlement; a re-attach under the same message identity is never a second run. - A client re-synced from the log sends source=log from then on, so the ring never serves its log cursors, even after it restarts and grows past them. - A live tail ends without a terminal as soon as its ring loses its head or restarts, so it re-attaches and re-syncs instead of reading re-sent text. - A replay that ends short of the terminal and cap holds its response at least 10 s (longer while parked), so a stalled run is not replayed every second. - replay_end is parsed with a schema tied to the protocol's reasons; an unknown reason ends the replay instead of passing as a run event. - The replay forwarder moves into session/run-replay.ts, the chat snapshot reader is shared by both chat routes, and checkForReplayGap is removed.
…shes, and check a busy tail's ring less often - A replay held after a park now ends as soon as Sim sees the run leave its park, and any held replay ends as soon as the run reaches a terminal, so an approval no longer freezes the view for up to 10 s. A park Sim has not yet marked, a stall and a cut connection keep the 10 s floor. - A live tail checks that its ring can still serve it only after a quiet poll or every eighth busy one, instead of two Redis reads on every 250 ms poll. - The recovery integration test re-sends a replayed go tool and re-hands a Sim call the dead controller already ran: it is resumed with its stored result, never run again, and the turn keeps one block per tool.
…play's key A deployment whose key may not call the worker's replay (401/403) now falls back to the replay_gap terminal as a missing run (404) does, instead of answering 503 until the client's reconnect budget runs out. A failed buffer TTL refresh during the chat-lock heartbeat is logged as such, not as a lock-extension failure.
…sor, and re-sync an expired ring - The worker replay is bounded like a stream leg: no response headers, or no bytes including keepalives, for the idle timeout ends it so the reader re-attaches. - A ring read that starts after the reader's next event (the ring trimmed its head between the gap check and the read) is never delivered; the reader re-attaches and re-syncs from the log, in both the live tail and batch reads. - An empty ring serves only a reader starting from cursor 0; a live run whose buffer expired under a reader re-syncs from the log, and a finished one answers its terminal since its transcript is persisted. - A leg that delivers events after a failure starts a fresh 30 s reachable window; its three retries still refill only after five minutes of delivered events. - The two new integration suites close their worker server and restore env even when they are skipped.
…tral agent-url mock
…he mid-tail trim race - A 401 or 403 from the worker's replay endpoint is logged with its status, so a rotated or wrong worker key is visible instead of every reader silently falling back to replay_gap. - A live tail whose ring trims past its cursor between polls ends without a terminal and never delivers the events after the gap.
02e0ecd to
6a57d07
Compare
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 27 files
You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Fix all with cubic | Re-trigger cubic
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
No issues found across 27 files
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
…and track the log re-sync as one stream id - processSSEStream takes an optional idle timeout and passes it to readSSELines, so the run replay uses the shared idle bound instead of its own reader wrapper. Callers that omit it are unchanged; the replay's header wait keeps its own timer. - A chat view re-syncs one stream at a time, so the log re-sync flag is the stream's id rather than a set.
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
No issues found across 28 files
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
No issues found across 28 files
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
Summary
This PR stops the replay ring from ending long Chat turns.
replay_gapre-sync is that enabling work. It is not a fix for areplay_gapobserved in production.Merge order
POST /api/streams/replay) deployed first. Its protocol types are synced here withbun run contracts:sync.replay_gapterminal.Byte-trimmed replay ring
The ring now trims by bytes as well as by count. The owner counter tracks exactly the retained bytes, so a long run slides instead of hitting the owner budget and failing.
Re-sync from the worker's log
The ring can't serve a reconnect when:
In those cases Sim re-syncs the reader from the worker's durable log through the read-only replay.
x-mothership-stream-replay: log. The client rebuilds the turn from an empty response.source=log, and the ring never serves that reader again. The ring and the log share no position to join on.replay_endframes are parsed with a schema tied to the protocol's reasons. An unknown reason ends the replay and is never forwarded as a run event.Recovery and snapshots on a ring that lost its head
Cost
A reconnect on a run whose ring lost its head replays up to the whole log. A tab that stays attached replays it again about every 5 minutes, when the worker's replay connection reaches its cap. It also replays at most every 10 s while a run is stalled. Sharing replays between readers is a worker follow-up.
Known limits
maxDuration.Next step: one durable read path
Key the ring by the worker's durable seq and serve every reader, live or late, from one read path. Cursors would then name positions in the durable log, and nothing would be re-synced or joined.
Test plan
bun run test:integration, all suites). The worker is mocked only at the HTTP boundary.replay-gap.integration.ts:source=logreaders stay on the log;stream-recovery.integration.ts: a controller dies on a run whose ring lost its head. The new controller recovers with the real lifecycle.replay-budget.integration.ts: a stream far past the owner budget; recovery and snapshots on a ring that lost its head.buffer-ttl.integration.ts.source=log.bun run lint,bun run type-check,bun run check:audits, fullapps/simvitest.