Skip to content

FE-1224, FE-1225: Add detached, reconnectable optimization runs to the optimizer and proxy them through NodeAPI#9067

Open
kube wants to merge 9 commits into
mainfrom
cf/fe-1225-nodeapi-detached-run-proxy
Open

FE-1224, FE-1225: Add detached, reconnectable optimization runs to the optimizer and proxy them through NodeAPI#9067
kube wants to merge 9 commits into
mainfrom
cf/fe-1225-nodeapi-detached-run-proxy

Conversation

@kube

@kube kube commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

🌟 What is the purpose of this PR?

Today an optimization study lives exactly as long as one browser HTTP connection: any transport reset kills the study (reproduced at trials 69/73 of 100 in SRE-831), and cancellation is implied by disconnect. This PR decouples them: a run is created detached, identified by a run_id, and any number of sequential consumers can attach, detach, and re-attach to its event stream with a cursor — the run itself is only ever stopped by an explicit cancel, a wall-clock ceiling, or the orphan reaper.

The optimizer is the single source of truth for run state, including who owns a run: NodeAPI shrinks to a thin, stateless proxy (authentication, rate limits, manifest validation, SSE→NDJSON conversion, error mapping).

Consolidates FE-1224 (optimizer engine, previously #9064) and FE-1225 (NodeAPI proxy) into one backend PR. The frontend cutover is stacked on top in #9066.

🔗 Related links

🔍 What does this change?

Optimizer (apps/petrinaut-opt)

  • POST /optimize/runs admits against the existing 4-slot cap (429 + Retry-After), starts CLI + study as a background task owned by the run, and returns 201 {"run_id"} immediately.
  • GET /optimize/runs/{run_id}/events?cursor=N (SSE) replays buffered frames with seq > cursor (id: carries the seq), then live-tails with heartbeats. Disconnecting never affects the run; a newer attachment supersedes a stale one, which ends with an event: superseded sentinel so its consumer stops cleanly instead of reconnecting into a supersede war.
  • DELETE /optimize/runs/{run_id} cancels explicitly and idempotently, with a prompt process-group close.
  • Slot lifetime = run lifetime; an orphan reaper cancels runs with no consumer for HASH_PETRINAUT_OPT_DETACH_GRACE_SECONDS (default 300).
  • Ownership: run creation carries the account's opaque x-hash-account-id tag (stamped by NodeAPI behind its auth). Per-account single-flight is enforced inside the admission lock, and attach/cancel answer 404 for a foreign or absent tag — identical to an unknown run, so run ids cannot be probed. Requests without the tag (local development, the website demo) create ownerless, openly attachable runs.
  • Observability: the events route is excluded from ASGI auto-instrumentation and opens a short manual SERVER span over the attach itself — the RED latency SLI measures "was the trigger fast", not the lifetime of the tail, while the service-graph edge is preserved. The study runs under the INTERNAL optimization.study span hanging off the creating request.

NodeAPI (apps/hash-api)

  • New authenticated proxy routes for create/attach/cancel; re-attachment gets a looser rate bucket than creation so reconnect backoff cannot 429 an account off its own live run.
  • Holds no run state: no ownership map, TTL sweep, pending-create occupancy, or liveness probe.
  • The legacy streaming POST …/optimize route is deleted rather than re-implemented on top of the engine.

Client (@local/petrinaut-optimizer-client)

  • The plain JSON endpoints are driven by a typed openapi-fetch client over the already-generated schema types; only the SSE attachment adapter (cursor replay, canonical-event decoding, terminal semantics) stays handwritten — its frame protocol is not expressible in OpenAPI.

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:

  • are internal and do not require a docs change

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

The changes in this PR:

  • do not affect the execution graph

⚠️ Known issues

🛡 What tests cover this?

  • apps/petrinaut-opt: run-engine unit tests (event log, attachment epochs/supersede, reaper, shutdown), API tests over the real endpoints (replay/cursor semantics, capacity + account single-flight, ownership visibility, orphan reaping), pump lifecycle tests (cancellation, wall-clock ceiling, correlation logging).
  • apps/hash-api: handler tests for create/events/cancel (validation without logging user content, account-tag forwarding, NDJSON replay, retryable timeout lines, superseded forwarding, 404/429 passthrough, disconnect compensation).
  • @local/petrinaut-optimizer-client: decoder and attach tests (seq mapping, terminal rules, oversized events), openapi-fetch adapter tests.

❓ How to test this?

  1. yarn dev with the optimizer running (infra/compose includes it).
  2. Start an optimization from the process editor and note the run_id in the network tab (POST /api/petrinaut-optimizer/optimize/runs).
  3. Kill the events request (dev tools → cancel) — the run keeps optimizing; GET …/events?cursor=N resumes from where you stopped without duplicating trials.
  4. DELETE …/optimize/runs/:runId stops it promptly; a second DELETE still answers 204.

@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)
petrinaut Ready Ready Preview Jul 24, 2026 1:32am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
hash Ignored Ignored Preview Jul 24, 2026 1:32am
hashdotdesign-tokens Ignored Ignored Preview Jul 24, 2026 1:32am

@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/apps > hash-api Affects the HASH API (app) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team type/eng > backend Owned by the @backend team area/apps labels Jul 19, 2026
@kube
kube marked this pull request as ready for review July 19, 2026 20:29
@kube
kube requested a review from YannisZa July 19, 2026 20:29
@cursor

cursor Bot commented Jul 19, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Large behavioral/API break (legacy optimize route removed) plus new distributed run lifecycle, ownership, and streaming edge cases; requires coordinated frontend deploy.

Overview
Replaces the single long-lived streaming POST …/optimize NodeAPI route with a detached run model: create returns a runId, events are consumed (and resumed) via GET …/runs/:runId/events?cursor=N, and DELETE …/runs/:runId cancels. NodeAPI stays a thin proxy—auth, validation, rate limits, and forwarding x-hash-account-id—while per-account single-flight and owner-only attach/cancel move to the optimizer.

The optimizer gains POST/GET/DELETE /optimize/runs… with a background study pump, sequenced SSE replay (id: / cursor), attachment supersession, orphan reaping, optional account ownership, trial/time ceilings, and telemetry tweaks (short attach span instead of tail-length auto-instrumentation). NDJSON events gain optional seq for client resume; replay attachments omit synthetic started and keep best null so the browser owns running best across reconnects.

Breaking: the legacy streaming optimize endpoint is removed; deploy must pair with the frontend cutover PR.

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

YannisZa
YannisZa previously approved these changes Jul 19, 2026
@kube
kube force-pushed the cf/fe-1224-petrinaut-detached-runs branch from fec71b8 to 760a6f0 Compare July 19, 2026 21:02
@kube
kube force-pushed the cf/fe-1225-nodeapi-detached-run-proxy branch from 8763d1f to a39600b Compare July 19, 2026 21:02
Comment thread apps/hash-api/src/petrinaut-optimizer/shared/optimization-run-owners.ts Outdated
@codecov

codecov Bot commented Jul 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.75710% with 61 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.35%. Comparing base (8daf815) to head (1848a8c).

Files with missing lines Patch % Lines
...mizer/create-petrinaut-optimization-run-handler.ts 77.77% 8 Missing and 6 partials ⚠️
...reate-petrinaut-optimization-run-events-handler.ts 81.13% 2 Missing and 8 partials ⚠️
...optimizer/shared/optimization-request-lifecycle.ts 81.08% 7 Missing ⚠️
...-optimizer/shared/validate-optimization-request.ts 75.86% 4 Missing and 3 partials ⚠️
...reate-petrinaut-optimization-run-cancel-handler.ts 78.57% 4 Missing and 2 partials ⚠️
...ut-optimizer/shared/forward-optimization-events.ts 73.91% 3 Missing and 3 partials ⚠️
...-optimizer/shared/optimization-run-test-harness.ts 91.22% 3 Missing and 2 partials ⚠️
...aut-optimizer/setup-petrinaut-optimizer-handler.ts 75.00% 2 Missing and 1 partial ⚠️
...aut-optimizer/shared/optimization-route-context.ts 80.00% 0 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9067      +/-   ##
==========================================
+ Coverage   59.32%   59.35%   +0.02%     
==========================================
  Files        1397     1404       +7     
  Lines      135621   135765     +144     
  Branches     6295     6315      +20     
==========================================
+ Hits        80462    80582     +120     
- Misses      54187    54200      +13     
- Partials      972      983      +11     
Flag Coverage Δ
apps.hash-api 12.19% <80.75%> (+1.67%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@kube
kube force-pushed the cf/fe-1224-petrinaut-detached-runs branch from 760a6f0 to a23b8b0 Compare July 23, 2026 16:22
@kube
kube force-pushed the cf/fe-1225-nodeapi-detached-run-proxy branch from a39600b to c79703e Compare July 23, 2026 16:23
@kube
kube changed the base branch from cf/fe-1224-petrinaut-detached-runs to main July 23, 2026 18:10
@kube
kube dismissed YannisZa’s stale review July 23, 2026 18:10

The base branch was changed.

@github-actions github-actions Bot added the area/tests New or updated tests label Jul 23, 2026
@kube
kube marked this pull request as draft July 23, 2026 18:10
kube added 2 commits July 23, 2026 21:01
The detached-run routes are the only optimization surface NodeAPI exposes:
the attached-study `POST /api/petrinaut-optimizer/optimize` route is
deleted rather than reworked on top of the run engine, and the account
occupancy tracker shrinks to the pending-create set that remains its only
job. The frontend cutover to the detached flow lands in the follow-up PR —
merge the two together, since a deployed frontend older than that PR can no
longer start optimizations once this one is live.
`GET /optimize/runs/{run_id}/events` live-tails for as long as a consumer
stays attached, so its auto-instrumented SERVER span measured the tail
(minutes) rather than the attach, pinning the latency SLI at worst-case and
only exporting on disconnect. The route is now excluded from ASGI
auto-instrumentation and instead opens a short manual SERVER span — with
the extracted upstream trace context — covering run lookup, cursor
resolution, and attachment registration, ending when the tail starts. The
service-graph edge and the 404 error signal are preserved.

Also re-syncs the optimizer OpenAPI schema and the generated client types,
which had drifted from the route metadata, and folds the richer
events/delete response descriptions into the route definitions so the
schema stays reproducible.
@kube kube changed the title FE-1225: Proxy detached Petrinaut optimization runs through NodeAPI FE-1224, FE-1225: Add detached, reconnectable optimization runs to the optimizer and proxy them through NodeAPI Jul 23, 2026
… and harden the run engine

- A superseded attachment now ends with an attachment-scoped
  `event: superseded` sentinel, decoded as a terminal, non-retryable
  `attachment_superseded` error. Without it the consumer saw a truncated
  stream, received a retryable error, and re-attached — superseding the
  newer attachment right back, ping-ponging forever (e.g. two tabs on the
  same run). NodeAPI forwards the event without releasing run ownership:
  the run lives on under the newer consumer.
- Re-attaches get their own, looser rate-limit bucket (60/min): reconnect
  backoff behind a flaky connection must not 429 an account off the event
  stream of its own live run.
- The study wall-clock timeout no longer misreports a completion whose
  sentinel is still in flight (the worker has already exited) as a failure.
- One bad run can no longer kill the orphan reaper: per-run reap work is
  exception-guarded (an unreaped orphan would hold its slot until the next
  create recreated the loop).
- The manual attach span no longer records expected 404s as exceptions,
  and /status/{run_id} liveness probes are excluded from HTTP
  instrumentation like the /status snapshot already was.
- Dead code: pump_events' unused local callback, the impossible
  non-tuple worker guard, and the always-true study-span check are gone.
Comment thread apps/petrinaut-opt/src/petrinaut_optimizer.py
Every optimization route repeated the same preamble — the correlated
request logger, the 401 auth check, the 503 unconfigured-optimizer check —
and two of them repeated the ownership-or-404 guard and the 502/504
upstream-failure response verbatim. They now live in
shared/optimization-route-context.ts, so each handler body is only its own
logic. No behavior change: the handler test suites pass unchanged.
@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 23, 2026 22:58 Inactive
…API to a proxy

The optimizer is the single source of truth for run state, including who
owns a run:

- Run creation carries the account's opaque `x-hash-account-id` tag
  (stamped by NodeAPI behind its authentication). The optimizer enforces
  per-account single-flight inside its existing admission lock — a pending
  set covers the CLI-initialization window — and attach/cancel answer 404
  for a foreign or absent tag, identical to an unknown run. Requests
  without the tag (local development, the website demo) create ownerless,
  openly attachable runs. Attach responses report `X-Requested-Trials` so
  consumers can size synthesized summaries without remembering manifests.

- NodeAPI keeps exactly the basics: authentication, rate limits, manifest
  validation, NDJSON conversion, and error mapping. Its shadow copy of run
  state is gone — the ownership map and TTL sweep, the pending-create
  occupancy set, the create-time liveness probe, and the
  release-ownership-on-terminal-delivery rules were all machinery for
  keeping that copy honest, and the staleness bugs they guarded against
  cannot exist anymore. The optimizer's 429 detail is forwarded so the
  browser still distinguishes account-busy from service capacity.

- The handwritten create/cancel/status client functions are replaced by an
  `openapi-fetch` client over the already-generated schema types (the
  create response is now a typed model instead of a plain dict). The SSE
  attachment adapter stays handwritten — the frame protocol is not
  expressible in OpenAPI — and now reads the requested-trial count from
  the response header.
@github-actions github-actions Bot added the area/deps Relates to third-party dependencies (area) label Jul 24, 2026

@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 1848a8c. Configure here.

PETRINAUT_OPTIMIZER_OPTIMIZE_RUN_PATH,
optimizationRateLimiter,
createPetrinautOptimizationHandler({ fetchImpl, logger, origin }),
createPetrinautOptimizationRunCancelHandler({ client, logger, origin }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cancel shares create rate limit

Medium Severity

DELETE …/optimize/runs/:runId reuses the same optimizationRateLimiter instance as create (10/min). Attachments were given a separate bucket specifically so a busy account is not 429’d off its own live run, but cancel was left on the create bucket. Failed or retried creates still consume that budget, so an account can be temporarily unable to cancel the run that is holding its single-flight slot.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1848a8c. 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 > hash-api Affects the HASH API (app) area/apps area/deps Relates to third-party dependencies (area) area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) area/tests New or updated tests type/eng > backend Owned by the @backend team type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

2 participants