FE-1226: Auto-reconnect Petrinaut optimization streams by run id and cursor#9066
Conversation
…cursor Decouple a Petrinaut optimization from the single browser connection that started it. A run is now created by id, its NDJSON event stream is (re-)attached with a seq cursor, and it is cancelled explicitly — so a dropped connection resumes instead of failing the run, and a reload can re-attach where storage allows. - petrinaut-core: every optimization event variant gains an optional, server-authoritative `seq`; the host capability gains optional createOptimizationRun / attachOptimizationRun / cancelOptimizationRun methods, so hosts that only implement `optimize` keep type-checking and working. - messages: new iframe->host optimizationCreate / optimizationAttach / optimizationCancel kinds and a host->iframe optimizationCreateResult reply; attachment streams reuse the existing responseStart/chunk/end/ error family keyed by request id. - host (process-editor): the shared NDJSON relay is factored out and used by both the legacy POST flow and the new cursor GET flow; create POSTs and replies with runId or a classified failure (status, Retry-After, safe message); cancel DELETEs fire-and-forget; the two legacy error replies that predate FE-1222 now carry their `protocol` category. - bridge: implements the three capability methods over those messages, rejecting creation failures with a classified PetrinautOptimizationTransportError that now also carries retryAfter. - provider: detached runs store runId/lastSeq/connectionState on the record, apply events idempotently (skipping replayed seq <= cursor), and re-attach with exponential backoff (1s doubling, capped 30s, reset on any received event) for network/protocol failures until 8 consecutive attachments fail, then surface the FE-1222 classified error; cancel aborts locally and stops the run server-side; active runs are recorded in sessionStorage and re-attached (cursor 0) on mount, silently dropping ids the service no longer knows; the legacy optimize() path is unchanged when the new methods are absent. - drawer: shows "(reconnecting…)" next to the status while a dropped stream is being re-established. - docs: optimization.md now describes automatic reconnection, reload re-attachment, and Cancel as the way to stop the server-side run.
Match the final FE-1225 browser-facing contract: - URLs: detached runs live under POST/GET/DELETE /api/petrinaut-optimizer/optimize/runs[/{runId}[/events]]; the legacy streaming POST keeps /optimize. The attach cursor is clamped to NodeAPI's ^\d{1,15}$ validation range. - Attachments emit no `started` event: the record turns `running` on the first applied event, and the test fakes now start with a trial line. - Attachment trials and summaries carry `best: null`, so the provider now computes the running best itself from every applied completed trial (using the manifest's objective direction), preferring `event.best` when the legacy stream provides it. The best therefore survives reconnects and reload replays instead of freezing at its pre-disconnect value. - Attachment `complete` counts are since-cursor, not totals: the detached flow keeps the record's accumulated per-trial counters and running best, taking only `requestedTrials` (the true total) and the status from the event. The legacy flow still trusts its summary. - A NodeAPI-authored terminal error event with `retryable: true` (its per-attachment window timed out while the run continues) now feeds the same reconnect-with-cursor path as a dropped connection, sharing the failure cap and its reset-on-progress; only when reconnecting is exhausted does the event's own message become the run's outcome. `retryable: false` events stay terminal, as does any error event in the legacy flow. - A mid-run reconnect that 404s (NodeAPI dropped the run) surfaces the classified http error immediately without further retries; the reload path still drops such records silently.
…emantics - Orphaned runs / account busy-lock: every give-up path in the attach loop (retryable-window exhaustion, non-reconnectable classified failure, unclassified throw) now fire-and-forget cancels the possibly-live run before surfacing the terminal error, releasing NodeAPI's per-account ownership slot so a follow-up run (e.g. Retry) isn't rejected as busy until the ownership TTL. The sessionStorage entry is only forgotten once that cancel resolves, so a reload during the same outage can still recover the run; and explicit Cancel/Remove fall back to the record's own runId once the attach loop has ended, so a user can still DELETE a run after a surfaced terminal error. - Docs: stop promising reload re-attachment the shipped sandboxed iframe cannot deliver (opaque origin, no sessionStorage). The page now states that reloading loses the view of a still-running optimization, that the run continues server-side and can block new optimizations, and to Cancel first when intending to run something else. A host-side storage bridge remains a follow-up feature. - Reconnect predicate: gateway statuses (502/503/504) on re-attach — NodeAPI restarting or deploying — now reconnect within the same failure cap; 404 and all other http failures stay immediately terminal. - Busy message: a create rejected 429 now explains that another optimization may already be running for the account, including the Retry-After delay when NodeAPI sent one. - Bridge create races: a late optimizationCreateResult for a create that already aborted/timed out locally (or an unknown request id) posts optimizationCancel for the orphaned runId; accepted creates are tombstoned briefly so duplicated replies never cancel a live run. - StrictMode: the reload re-attach effect now cleans up after itself (aborts its loops and drops its records) instead of a provider- lifetime ref guard, so double-invoked effects rebuild cleanly rather than duplicating restored runs. - Honest connection state: attachOptimizationRun gains an optional onAttached callback (fired by the bridge when the attachment response arrives OK) which the provider uses to clear "(reconnecting…)" on quiet-but-healthy reattachments. Deliberate trade-off, documented in code: only received events reset the failure counter, so attachment windows that keep dying without progress still exhaust the cap.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
|
Semgrep found 1
No validation of origin is done by the addEventListener API. It may be possible to exploit this flaw to perform Cross Origin attacks such as Cross-Site Scripting(XSS). |
PR SummaryMedium Risk Overview
HASH process editor extends the iframe bridge with Optimizations provider uses the detached path when available: tracks Reviewed by Cursor Bugbot for commit 198a2ce. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 198a2ce. Configure here.
| // A normal end implies a terminal event was decoded (the stream | ||
| // parser rejects endings without one), so the record is settled. | ||
| removeStoredActiveRun(runId); | ||
| return; |
There was a problem hiding this comment.
Deduped retryable error stalls run
Medium Severity
In runAttachLoop, a re-attached error event with retryable: true and a seq already recorded in lastSeq is skipped by seq deduplication before the retryable-interruption logic runs. The NDJSON parser still treats that line as terminal, so the attach iterator can finish while sawTerminalEvent stays false. The loop then clears session storage and returns, leaving the optimization record in a running state with no further updates.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 198a2ce. Configure here.


🌟 What is the purpose of this PR?
Makes the Petrinaut optimization UI survive transport resets: instead of failing a run when the stream drops (the staging
network error), the frontend now creates a detached run identified by arunIdand auto-reconnects to its event stream with a cursor, deduplicating replayed events. Cancellation becomes an explicit server-side action, and a run that can't be resumed is cancelled rather than orphaned.🔗 Related links
🚫 Blocked by
🔍 What does this change?
petrinaut-core: optionalseqon the canonical event schema; additive optional capability methodscreateOptimizationRun/attachOptimizationRun(withcursor,onAttached) /cancelOptimizationRun— old hosts still type-check (changeset included).optimizationCreate/optimizationAttach/optimizationCancelmessages; the host proxiesPOST .../optimize/runs,GET .../optimize/runs/{runId}/events?cursor=N,DELETE .../optimize/runs/{runId}; settled-create tombstones so a late create result can never leak an unknown live run.runId/lastSeq/connectionStateon the record; seq-deduped replay; locally computed running best (attachment events carrybest: null); reconnect with exponential backoff (1s→30s, 8 consecutive failures) onnetwork/protocolfailures, NodeAPI-authoredretryable: trueerror events, and transient 502/503/504; every give-up path best-effort cancels the run server-side so an unreachable run can't 429-lock the account; explicit Cancel falls back to the record's own runId; 429-busy creates get a specific message withRetry-After.Pre-Merge Checklist 🚀
🚢 Has this modified a publishable library?
This PR:
📜 Does this require a change to the docs?
The changes in this PR:
🕸️ Does this require a change to the Turbo Graph?
The changes in this PR:
seqschema addition also exists (identically) on the backend chain — the eventual merge needs a one-line dedupe.🛡 What tests cover this?
@hashintel/petrinaut(react + UI): 186 tests — detached create/attach happy path, reconnect-with-cursor + replay dedupe, post-reconnect best recomputation, retryable-event windows, give-up-cancels-run, Remove-cancels-via-record-runId, 503-then-success, 404-terminal, StrictMode double-mount, quiet-reattachment connection state, reload re-attach + silent 404 drop, legacy fallback.@apps/hash-frontendprocesses: 19 tests — bridge create/attach/cancel, late-create-result cancellation, URL/cursor handling.@hashintel/petrinaut-core: 838 tests — seq optionality, capability type.❓ How to test this?
🤖 Generated with Claude Code