Skip to content

fix(deploy): resend a live deploy only when Harper refused it, never after it may have started - #1898

Draft
dawsontoth wants to merge 2 commits into
claude/1787-stage-and-activatefrom
claude/1889-live-deploy-no-resend
Draft

dawsontoth wants to merge 2 commits into
claude/1787-stage-and-activatefrom
claude/1889-live-deploy-no-resend

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1876 (claude/1787-stage-and-activate), the top of the deploy chain #1812 → #1817 → #1821 → #1824 → #1868 → #1874 → #1876 → this PR. The deploy code it changes (deployRequestBody, rollout-job following, staged activation) exists only on that chain, so it targets #1876's branch. Retarget it as the chain merges.

⊙ Problem

When a live (SSE) deploy didn't get an event stream, Studio sent the same deploy_component again as a buffered POST, even when the first request may already have started the deploy. onDeployComponentSubmit fell back on every SSEUnsupportedError, on the premise that "no deploy has started yet". streamOperation throws that error for a gateway 502/503/504, for a 2xx that is not text/event-stream, for a connection that drops after the POST went out, and when the 120s idle watchdog fires before headers arrive. Harper starts an SSE-capable operation as soon as it accepts the request: the SSE branch of handlePostRequest sets text/event-stream and runs the operation in the background (harper server/serverHelpers/serverHandlers.js:188-194, the same at v5.1.0:149-155), and it writes : stream open straight away (server/serverHelpers/progressEmitter.ts:79-86). So any of those failures can follow a deploy that is already running. The fallback then deployed the package a second time, with a second install and a second rolling restart, or with two deploys of one project racing. The fallback is a plain fetch with no interceptors, so #1891's fix to the gateway retry doesn't reach it. Closes #1889.

❓ Your call: Should this land before the chain under it does? It changes ten lines of deploy logic plus tests, and it only needs #1876 because the code it touches lives there. Carried to stage without the chain, the same fix is one hunk in deployComponent.ts and a field on SSEUnsupportedError.

💡 Solution

SSEUnsupportedError now carries the response's status. It is undefined when no response arrived, and streamOperation fills it in. The deploy falls back only on a 4xx, because Harper refuses a request before it starts the operation with a 4xx: authentication fails 401 in authHandler (serverHandlers.js:139), and body validation and chooseOperation's permission check run before the SSE branch (:170, :177). harper server/DESIGN.md:231 states the contract: such a refusal is answered as a JSON error status, and the stream keeps its in-band event: error at 200. A 4xx from an intermediary (a 413, a 429, a 404 route) is also a refusal before forwarding. Everything else becomes SSEInconclusiveError, with the original error as cause. The import, redeploy and activate callers already handle that by checking the result instead of sending again. A live import or redeploy runs the post-deploy health check. A stage or an activation says it "may not be" done and points at the Deployments list.

streamOperation doesn't make the decision itself. It reports the status and leaves the choice to the caller, because get_deployment is a read and may fall back to polling on anything. Its path is unchanged.

❓ Your call: A 503 from Harper's own request-queue shed (harper server/http.ts:1143-1150) also means the deploy never started, but status alone can't separate it from a gateway's 503, so that 503 also becomes inconclusive. The user then sees a health check or a "may not be staged" warning instead of a retry. A shed is the rarer case, and repeating a deploy costs more than a check does.

❓ Your call: For a new import whose request never reached Harper (for example DNS failed), the inconclusive path's health check finds no component and reports "deployed, still verifying". The old fallback would have sent it once more and failed loudly. That message comes from the existing inconclusive report, which a drop after the deploy started also uses. Improving it means following get_deployment for the result, which is a larger change and is not attempted here.

⚖️ Alternatives

  • Let streamOperation throw SSEInconclusiveError for the ambiguous cases. Rejected: that would change the transport's contract for every caller, including the get_deployment read, to fix one write. Whether a resend is safe depends on whether the request is a write, which only the caller knows.
  • On a 2xx JSON response, parse the body as the deploy result. That would report a proxy-rewritten success precisely. Rejected for now: Studio streams only to direct connections on Harper 5.1+, which answer text/event-stream, so this response comes from something rewriting it. The issue's direction, treating it like any other inconclusive outcome, is enough to stop the double deploy.
  • Fall back on nothing. Rejected: a 401 would then end the deploy, when the buffered path's token-refresh interceptor would have recovered it.
  • Check get_deployment before resending. That finds the outcome itself rather than inferring it. Rejected for this PR: a deploy whose stream never opened has no deployment_id to look up, and inconclusive callers already have a check.

🔧 Changes

Overlap with #1841 → #1871 (deploy failure guidance and inline display). #1871 rewrites the import and redeploy callers' error branches, including how they display SSEInconclusiveError. This PR leaves those callers alone. It only sends them SSEInconclusiveError in cases that used to become a second POST. Whichever lands second should check that its inconclusive branch still fits a stream that never opened, as well as one that dropped. #1890's PR (#1897, on #1891) separately makes a buffered write's gateway status read as "outcome unknown". The two don't touch the same lines.

✅ Verification

  • Through the real stream: deployComponent.fallback.test.ts runs onDeployComponentSubmit → deployComponentStream → streamOperation with only fetch and the connection resolver stubbed, and counts the buffered client's POSTs. A gateway 502/503/504, a 2xx JSON response, a dropped connection, and the idle watchdog firing before headers (fake timers, 120s) each reject SSEInconclusiveError and send nothing more. A 401 or 403 still goes out once on the buffered path.
  • Unit: deployComponent.test.ts falls back on 400/401/403/404/406/413/429, and never resends after 500/502/503/504, a 2xx, or no status, keeping the original error as cause. The old "falls back when the stream is unsupported" test asserted the bug, so it now uses a 4xx. streamOperation.test.ts checks that the status is carried, and is undefined when fetch rejects.
  • Fails on base: with the three source files restored from claude/1787-stage-and-activate (2d5c9e09c), 14 of the new tests fail with the bug's signature: promise resolved "{ message: 'Successfully deployed: m…' }" instead of rejecting, which is the second POST.
  • Mutation checks, each reverted afterwards. Letting 5xx fall back turns 7 tests red. Letting a missing status fall back turns 3 red.
  • Gate: the pre-commit hook's full vitest run passed 408 files / 3,855 tests. npx tsc -b, npx oxlint --format stylish . and npx dprint check each exited 0.
  • Not browser-verified. A gateway status or a dropped stream can't be produced on demand against a live instance, so the fetch-level test above is how this was verified end to end.
  • Cross-model review: one full round, codex (graded) and gemini, verdict COMMENTS. Codex's one finding is fixed in the second commit: deployComponentStream's doc comment still told every caller to resend buffered on SSEUnsupportedError. Gemini's comment nits are fixed in the same commit. That commit changes only comments, so it was not reviewed again. The Cursor leg failed no-receipt (the 1Password SSH agent refused git fetch), and the Harper domain leg failed auth (expired OAuth session), so no adjudication ran. That is an infrastructure gap, not an open finding.

🤖 Generated with Claude Code (Claude Opus 5.5); posted via @dawsontoth.

Related PRs: #1876 overlaps (stack base — the deploy code this changes lives there), #1868 overlaps (below #1876 in the chain; shares deployComponent tests and DESIGN.md), #1841 overlaps (heads the #1871 chain, which rewrites the deploy callers' error branches), 2 others independent
Complexity: low

Review-Coverage: authored=claude; ran=codex,gemini; blocked=cursor-composer(no-receipt),domain(auth); declined=cursor-grok,cursor-kimi,cursor-muse; rounds=1; full=1 @ 3e81ae4

Review-Attention: read ~3m (raised: degraded review) @ 3e81ae4

dawsontoth and others added 2 commits October 11, 2026 05:32
…after it may have started

When the SSE deploy got no event stream, onDeployComponentSubmit fell back
to the buffered deploy_component on any SSEUnsupportedError: a gateway
502/503/504, a 2xx that was not a stream, a dropped connection, or the
120s idle watchdog firing before headers. Harper starts an SSE operation as
soon as it accepts it and answers with a 200 stream, so each of those can
follow a deploy that is already running, and the fallback deployed the same
package a second time (a second install and rolling restart, or two deploys
of one project racing).

SSEUnsupportedError now carries the response status. The deploy falls back
only on a 4xx, which Harper answers for a request it refused before running
it (authentication, permissions, validation). Anything else becomes
SSEInconclusiveError, which the import, redeploy and activate callers
already answer by checking instead of resending.

Closes #1889

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
deployComponentStream's contract still told every caller to retry on SSEUnsupportedError, which is the resend this branch removes. Also trims two comments that restated the design note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request updates the live (SSE) deployment logic to prevent duplicate deployments. It ensures that a fallback to the buffered POST path only occurs when a 4xx status code is received, proving the server never started the deployment. For other inconclusive errors (such as 5xx gateway errors, dropped connections, or non-stream 2xx responses), the system now throws an SSEInconclusiveError instead of resending. The changes include adding a status property to SSEUnsupportedError, updating the deployment submission flow, adding comprehensive unit tests, and updating the design documentation. I have no feedback to provide as there are no review comments.

@dawsontoth
dawsontoth added this pull request to stack #1901 October 11, 2026 21:25
@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 68.43% 10482 / 15317
🔵 Statements 68.58% 11177 / 16296
🔵 Functions 62.12% 2718 / 4375
🔵 Branches 63.66% 8089 / 12705
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/integrations/api/instance/applications/deployComponent.ts 95.83% 100% 83.33% 95.83% 117-121
src/integrations/api/instance/applications/deployComponentStream.ts 84.21% 57.89% 100% 84.21% 66-70
src/integrations/api/sse/errors.ts 100% 100% 100% 100%
src/integrations/api/sse/streamOperation.ts 79.36% 75% 66.66% 78.68% 129, 164-183
Generated in workflow #2110 for commit 3e81ae4 by the Vitest Coverage Report Action

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A live deploy whose stream fails to open is sent again as a buffered deploy, even after a gateway status

1 participant