feat(generations): recover a generate_media result across a reload - #5630
Conversation
A `generate_media` reply is an `rpc_response` carrying no `job_id` and no
`thread_id`, so `WebSocketClientSession.sendMessage` falls past both replay
branches and writes it to the socket that asked, dropping it if that socket has
gone. Re-subscribing after a browser reload therefore recovers nothing: the
frame was already delivered to a socket that no longer exists. The storyboard
board and the timeline sequence both had this, and both documented it as a
limit of what reattachment could do.
The result was never actually lost. Every `generate_media` call — metered or
BYOK — opens a `predictions` row through `createGenerationRun` before the
provider is called and closes it with its status, its assets and its error;
`generation-seam-audit.test.ts` fails on a provider media call outside that
seam. What was missing was the link: the client persisted a request id, the
server persisted a row, and nothing connected them.
So `origin.request_id` now carries the client's own RPC request id onto the
row, indexed as `(user_id, request_id)`, and `lookup_generations` answers
`{status, asset_ids, error}` for a batch of them — the caller's rows only,
capped at `MAX_REQUEST_ID_LOOKUP`. Both flows resolve their persisted entries
through it on open: a row that settled while the client was away lands from the
row, and only what the row still calls `running` is subscribed. An id with no
row comes back absent and is subscribed for, because the row may not be open
yet and reading unknown as failed would discard a render still in flight. A
lookup that cannot run at all yields an empty map, so an older server degrades
to exactly the behaviour it had before.
This removes the 30-minute deadline that previously failed such a clip: the
clip no longer has to wait out a window to learn what already happened.
Landing from the row also clears any subscription still open for that clip.
Reopening a sequence inside one page session leaves the earlier subscription
live, and without the clear its late reply appended the same version a second
time — the test fails with two identical takes.
`lookup_generations` is not in `APP_SESSION_COMMANDS`, so a deployed app's
visitor cannot call it; `app-session-scope.test.ts` pins that set's size, so
adding it later has to be deliberate.
Verified: the migration applied to a table without the column adds it and its
index, keeps existing rows, and runs twice without error; the schema-parity,
dialect-parity and migration-chain tests each caught a real omission first (the
bootstrap DDL, then the Postgres schema, then the chain).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TL39a2ajm5iVkRrnjbVER4
…iption Blocking bug in the previous commit, found in review. Recovering a request the row already called terminal worked; the `running` case did not, and it was the common one. `GlobalWebSocketManager.subscribe` is a client-side map with no replay buffer, and the server writes an `rpc_response` to the socket that asked. So a reply that lands while that socket is gone reaches nobody, and a handler installed afterwards on a new socket has nothing to receive. Subscribing to a request the row still calls `running` therefore waits forever — and because the previous commit removed the reattachment deadline, it waited forever instead of failing after thirty minutes. That was a regression on this path. The row is now what recovers. `watchGeneration` polls `lookup_generations` for every outstanding id in one batched call, backing off from 2s to 15s, until each row reaches a terminal state or its own thirty-minute window runs out — after which the surface fails and offers Retry rather than rendering forever. The subscription runs alongside and wins when it can, which is only when the socket is the same one the request went out on. That also closes the window between a lookup and the subscription that follows it, where a reply arriving in between reached no handler. Watching starts at the send, not only at reattachment. A socket that drops and reconnects without a page reload leaves the reply addressed to a server session that is gone in exactly the same way, and nothing re-runs reattachment in that case. The test that let this through asserted the subscription was installed and fired the handler by hand, which is not something that can happen after a reload. The replacements never touch the handler map: the only thing that can settle them is the row being read. Against the previous commit they fail — 2 in the timeline suite, 2 in the storyboard suite, 1 for the reconnect case. Two faults in the poller itself, each with a test: a settle callback that threw abandoned every other request settling in the same tick, which is a whole board; and a newly watched request inherited the backed-off interval, for which resetting the delay was not enough because a timer was already armed at the old one. Typecheck caught a dead line in the new test — `loadSequenceId` does not exist, and optional chaining had made the call a silent no-op. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TL39a2ajm5iVkRrnjbVER4
|
You're right on both counts, and the test I wrote is the tell. Fixed in
The row is now the recovery mechanism; the subscription is only a latency optimization. Watching starts at the send, not only at reattachment. Fixing yours surfaced an adjacent hole: a socket that drops and reconnects without a page reload leaves the reply addressed to a dead server session in exactly the same way, and nothing re-runs reattachment in that case. Making the row authoritative everywhere closes both and leaves one invariant instead of two. The replaced tests never touch the handler map — the only thing that can settle them is the row being read. Against the previous commit: 2 red in the timeline suite, 2 red in the storyboard suite, 1 red for the reconnect case. Two faults in the poller itself, found re-reading it and each now tested: a settle callback that threw abandoned every other request settling in the same tick (a whole board), and a newly watched request inherited the backed-off interval — resetting the delay wasn't enough, since a timer was already armed at the old one. Typecheck also caught a dead line in the new test ( I chose polling over a server-side replay mechanism: it needs no change to the session lifecycle and reuses the row this PR already adds. Steady-state cost is one batched RPC per tick while anything is in flight, and none otherwise. Verified: harness gate 6/6, Generated by Claude Code |
CI on the superseded head
|
|
georgi
left a comment
There was a problem hiding this comment.
Reviewed the full diff. Well-engineered feature that solves a real problem (lost generate_media results across page reloads). The implementation is sound:
landDirectGenas a single landing function for both socket reply and row lookup, withclearInFlightat the top preventing duplicateslookupGenerationsnever throws; falls through to subscription-only behavior on failuregenerationWatchbatches all watched ids into one RPC per tick- Schema migration is safe and idempotent (nullable column +
IF NOT EXISTSindex) Prediction.byRequestIdsfilters byuserId(no cross-user leakage), bounded byMAX_REQUEST_ID_LOOKUP = 128- Correctly excluded from
APP_SESSION_COMMANDS
One issue: StoryboardGenerationStore.ts has an indentation error in handleShotJobMessage. The lines after const response = ... inside the if (message.type === "rpc_response") block are indented at the outer function body level instead of inside the if block. Behavior is unaffected (braces scope correctly) but it misleads readers.
CI: test-app failure is pre-existing on main. Some other checks are still running.
Generated by Claude Code
|
| Test | Assertion | State of the source |
|---|---|---|
| stagger unit | combobox named /in animation stagger unit/i |
No unit control. ClipAnimations.tsx:330 hardcodes unit: "word", and the panel renders only the offset field, labelled "<role> animation word stagger offset" |
| stagger past clip | text /staggered over 4 words this runs 3100ms/i |
String appears nowhere outside the test |
| in + out outrun clip | text /in and out together run 1400ms/i |
String appears nowhere outside the test |
| unreadable easing | /not an easing this build reads; plays linear/i on a custom-curve keyframe |
UNPARSEABLE_EASING_HINT exists (InspectorMotionFields.tsx:82) and renders on the animation's own easing field — that sibling test passes — but the custom-curve keyframe rows never render it |
grep for those three caption strings across web/src outside __tests__ returns nothing.
Proposed patch (for whoever owns the motion-graphics work)
Two coherent options, and the choice is a product call about what actually shipped, not something to guess at from a red test:
- Build the UI the tests describe — add a stagger-unit select bound to
animation.stagger.unit(replacing the hardcoded"word"atClipAnimations.tsx:330, with the aria-label the test names), add the two over-run captions, and renderUNPARSEABLE_EASING_HINTon custom-curve keyframe easing fields as well as the animation's own. - Bring the tests to what shipped — drop or adjust the four assertions to the controls that exist.
I have not made either change here: it would widen a PR about generate_media recovery into the timeline Inspector, and it is not mine to decide which way it goes.
I have not re-run this job — it is deterministic and reproduces on main, so a re-run would only repeat it. My one re-run went to the Run workflow suite in browser timing failure above.
This blocks more than this PR. test-app is a required check, so every PR merging into main is red on it until this is resolved — including anything with auto-merge armed.
Generated by Claude Code
Review finding. Splitting `handleDirectResponse` into `settleDirectShotJob` moved its body into the `rpc_response` branch of `handleShotJobMessage` but left the lines at the outer function's indent, so the block reads as if it runs unconditionally. Braces scope it correctly and behaviour is unchanged; it only misleads whoever reads it next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TL39a2ajm5iVkRrnjbVER4
|
Indentation fixed in You were right about the cause: splitting Verified: On CI, agreeing with your read and adding the evidence: Generated by Claude Code |
What changed
A
generate_mediareply is anrpc_responsecarrying nojob_idand nothread_id, soWebSocketClientSession.sendMessagefalls past both replay branches and writes it to the socket that asked, dropping it if that socket has gone. Re-subscribing after a browser reload therefore recovers nothing — the frame was already delivered to a socket that no longer exists. Both the storyboard board and the timeline sequence had this, and both documented it as the limit of what reattachment could do (raised as F1 on #5612).The result was never actually lost. Every
generate_mediacall, metered or BYOK, opens apredictionsrow throughcreateGenerationRunbefore the provider is called and closes it with its status, its assets and its error;generation-seam-audit.test.tsfails on a provider media call outside that seam. What was missing was the link: the client persisted a request id, the server persisted a row, and nothing connected them. Soorigin.request_idnow carries the client's own RPC request id onto the row, indexed as(user_id, request_id), and a newlookup_generationsRPC answers{status, asset_ids, error}for a batch of them — the caller's rows only, capped atMAX_REQUEST_ID_LOOKUP. Both flows resolve their persisted entries through it on open: a row that settled while the client was away lands from the row, and only what the row still callsrunningis subscribed. This also removes the 30-minute deadline that used to fail such a clip — it no longer has to wait out a window to learn what already happened.Two degradations are deliberate. An id with no row comes back absent, not failed, and is subscribed for: the row may not be open yet, and reading unknown as failed would discard a render still in flight. A lookup that cannot run at all — an older server, a socket that will not connect — yields an empty map, so every entry falls through to a subscription and the behaviour is exactly what it was before this existed.
lookup_generationsis not inAPP_SESSION_COMMANDS, so a deployed app's visitor cannot call it.app-session-scope.test.tspins that set's size, so adding it later has to be deliberate.Verification
npm run test:affected—packages/models905/905,packages/execution17/17,packages/websocketrpc-readonly-commands13/13, web 526 tests across 61 suitesnpm run typecheck— web exit 0;packages/{models,execution,websocket}tsc --noEmitexit 0npm run lint— exit 0, and.oxlintrc.anti-slop-enforced.jsonexit 0 on every changed treenpm run dev:nodetool -- harness gate --base origin/main— 6/6 selfchecks passednpm run capabilities:check— current at 275 (no capability added or re-declared)Every behavioural change was observed failing first:
request_idfromopenRowAssertionError: expected null to be 'req-abc'reattachSequenceJobsreattachBoardJobsclearInFlightfromlandDirectGenReceived length: 2— the same take appended twicerequest_idfrom the bootstrap DDLSqliteError: no such column: "request_id"The migration was applied to a real table lacking the column: it adds the column and the index, keeps existing rows, runs twice without error, and the
(user_id, request_id)query the lookup makes returns the row.Three schema checks each caught a real omission before I found it myself — the bootstrap DDL, then the Postgres schema (
schema-pg/predictions.ts, which I had wrongly assumed did not exist), then the migration chain. That is the parity suite doing its job, and the reason the column reaches all four places.Agent capabilities
No capability added, and no capability's declared contract changed.
capabilities:checkpasses at 275.New checks
No new rule or audit — the new tests pin behaviour rather than adding a check. Each was inverted once and observed failing; the output is in the table above.
🤖 Generated with Claude Code
https://claude.ai/code/session_01TL39a2ajm5iVkRrnjbVER4
Generated by Claude Code