Skip to content

FE-1226: Auto-reconnect Petrinaut optimization streams by run id and cursor#9066

Open
kube wants to merge 3 commits into
cf/fe-1222-classify-petrinaut-optimization-transport-errorsfrom
cf/fe-1226-petrinaut-optimization-reconnect
Open

FE-1226: Auto-reconnect Petrinaut optimization streams by run id and cursor#9066
kube wants to merge 3 commits into
cf/fe-1222-classify-petrinaut-optimization-transport-errorsfrom
cf/fe-1226-petrinaut-optimization-reconnect

Conversation

@kube

@kube kube commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

🌟 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 a runId and 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

  • FE-1226 — this issue
  • FE-1223 — parent issue with the full contract
  • SRE-831 — the transport-reset root cause this makes survivable

🚫 Blocked by

🔍 What does this change?

  • petrinaut-core: optional seq on the canonical event schema; additive optional capability methods createOptimizationRun / attachOptimizationRun (with cursor, onAttached) / cancelOptimizationRun — old hosts still type-check (changeset included).
  • Bridge + host: new optimizationCreate/optimizationAttach/optimizationCancel messages; the host proxies POST .../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.
  • Provider: detached flow with runId/lastSeq/connectionState on the record; seq-deduped replay; locally computed running best (attachment events carry best: null); reconnect with exponential backoff (1s→30s, 8 consecutive failures) on network/protocol failures, NodeAPI-authored retryable: true error 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 with Retry-After.
  • sessionStorage-based reload re-attach where storage exists (StrictMode-safe); the production sandboxed iframe has no storage, so the user docs now state reload behavior honestly (a host-side storage bridge is a noted follow-up).
  • Drawer shows "(reconnecting…)" while re-attaching.

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

This PR:

  • modifies an npm-publishable library and I have added a changeset file(s)

📜 Does this require a change to the docs?

The changes in this PR:

  • require changes to docs which are made as part of this PR

🕸️ Does this require a change to the Turbo Graph?

The changes in this PR:

  • do not affect the execution graph

⚠️ Known issues

  • Reload re-attachment does not work in the production sandboxed iframe (opaque origin, no storage) — the run continues server-side and is reclaimed by the optimizer's detach grace; docs describe this honestly. Follow-up: host-side storage bridge or a run-listing endpoint.
  • The petrinaut-core seq schema 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-frontend processes: 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?

  1. Run the stack with the FE-1225 NodeAPI branch; open a process and start a 100-trial optimization.
  2. Kill the network (or the connection) mid-run for <30s: the drawer shows "(reconnecting…)", then resumes from the cursor with no duplicate trials.
  3. Keep the network down >~91s: the run surfaces a classified error, and the account is immediately free to start a new run (the orphan was cancelled).
  4. Cancel explicitly: the run stops server-side promptly.

🤖 Generated with Claude Code

kube added 3 commits July 19, 2026 20:37
…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.
@kube kube self-assigned this Jul 19, 2026
@vercel

vercel Bot commented Jul 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hash Ready Ready Preview, Comment Jul 19, 2026 8:15pm
petrinaut Ready Ready Preview, Comment Jul 19, 2026 8:15pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hashdotdesign-tokens Ignored Ignored Jul 19, 2026 8:15pm

@github-actions github-actions Bot added area/apps > hash* Affects HASH (a `hash-*` app) area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team area/apps labels Jul 19, 2026
@semgrep-code-hashintel

Copy link
Copy Markdown

Semgrep found 1 insufficient-postmessage-origin-validation finding:

  • apps/hash-frontend/src/pages/processes/[uuid]/embed.page/create-bridge-petrinaut-optimization.ts

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).

@cursor

cursor Bot commented Jul 19, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches long-running optimization lifecycle, account concurrency (429), and server-side cancel on failure paths; behavior is heavily tested but depends on NodeAPI detached-run routes being deployed for the new flow.

Overview
Petrinaut optimizations can survive dropped streams and (where storage works) page reloads by switching from a single long-lived HTTP stream to detached runs: create returns a runId, events are consumed via attach with a seq cursor, and Cancel stops the run on the server—not just the tab’s connection.

petrinaut-core adds optional seq on optimization events and optional capability methods createOptimizationRun, attachOptimizationRun (with cursor and onAttached), and cancelOptimizationRun; hosts that only implement optimize keep working.

HASH process editor extends the iframe bridge with optimizationCreate / optimizationAttach / optimizationCancel, proxies NodeAPI POST/GET/DELETE on hard-coded /runs routes (cursor sanitized on the host), shares NDJSON relay for legacy and attach flows, and cancels late successful creates when the iframe already aborted.

Optimizations provider uses the detached path when available: tracks runId, lastSeq, and connectionState; dedupes replayed events; maintains running best when attachments send best: null; reconnects with backoff on network/protocol failures, retryable error events, and 502/503/504; on give-up or user cancel/remove, best-effort server cancel to avoid orphaned runs blocking new work; persists active runs in sessionStorage for reload re-attach (sandboxed iframe has no storage—docs updated). The drawer shows (reconnecting…) while re-attaching.

Reviewed by Cursor Bugbot for commit 198a2ce. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 198a2ce. Configure here.

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

Labels

area/apps > hash* Affects HASH (a `hash-*` app) area/apps area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

2 participants