Skip to content

feat(deploy): follow a rolling deploy's restart job, and show canary certification - #1868

Draft
dawsontoth wants to merge 2 commits into
claude/1602-deployments-poll-policyfrom
claude/1785-deploy-canary-job-following
Draft

dawsontoth wants to merge 2 commits into
claude/1602-deployments-poll-policyfrom
claude/1785-deploy-canary-job-following

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

⊙ Problem

Stacked on #1824 (claude/1602-deployments-poll-policy), which stacks on #1821 → #1817 → #1812. Review and merge those first; this diff is only the two commits above #1824. The PRs for #1786 and #1787 stack on this one.

Studio deploys with restart: 'rolling'. The node that receives the deploy answers once its own part is done and hands the other nodes to a restart_service job, named in the response's restartJobId. Studio never followed that job, so a node that rejected or failed the release read as a successful deploy: the post-deploy health check reads the one node Studio reaches, and the deployment record is written only by the receiving node, so it says success while the job runs and after it fails.

From Harper 5.4 (HarperFast/harper#2981, merged in fa9b6f857, not yet tagged) a restarting deploy is also certified in a canary worker, and Studio read none of it: not the certification outcome, not a rejection's decision, and not the warning events a deploy streams when a peer installed something different (5.3.1).

❓ Your call: the false success is real today on every rolling deploy to a cluster, so following the job is warranted now; the 5.4 certification half targets an untagged Harper release and could wait for it. I kept them together because both read the same deploy result and share one reporting path, and nothing in flight supersedes either (#1841 changes the failure text, not when a deploy is done).

💡 Solution

The deploy mutation now follows the restart job before it settles. When the result carries restartJobId, it polls get_job until the job ends: a failed job rejects with a new DeployRolloutError naming each node, and a job Studio could not follow resolves as unfollowable, which no caller reports as success.

What I verified in Harper's source:

  • restartJobId exists since 4.6 (release_4.6.9:components/operations.js:449), so following applies to every version that returns it. A response without one behaves exactly as before: no poll, no timer, no event.
  • hdb_job is in NON_REPLICATING_SYSTEM_TABLES (harper resources/databases.ts:337), so only the receiving node can answer get_job. Central manager's cluster proxy sends every operation to the first running instance (central-manager src/resources/cluster/Cluster.js:376-379), so over the proxy the deploy and its polls reach the same node. A direct connection to a cluster's address can reach another node, which answers []; a row missing for 20 seconds counts as not-found.
  • The job row is written when the job starts and when it ends (harper server/jobs/jobProcess.ts:72-87), so every node's outcome arrives at once. "Show each node as it lands" is not possible by polling; harper deploy restart=rolling exits 0 before the other nodes have taken the release harper#3058 weighs streaming it.
  • Two result shapes. A 5.4 certified rollout records result.activated: [{node, certification, message}], or fails with message: {error, activated} (harper bin/restart.ts:232, :235, :258, :260). The plain restart job (4.6–5.3, and 5.4 when no canary was armed) records result.replicated, where a node the job could not reach is listed with its error and the job still completes (:363, :385, :397), so that node reads "restart not confirmed". A finished job in neither shape is unrecognized, never a success.
  • certification on success is certified | uncertified | unavailable | not-requested (harper components/operations.js:817, :1073); a rejection fails the deploy with the decision object on both the streamed error event and the buffered body (:1094, :1101-1109, built by certificationFailure at :1222-1247). The copy for an unchecked release comes from Harper's own reference ("Certifying a release in a canary worker").
  • warning events ship in 5.3.1 (v5.3.1:components/operations.js:945).

Gating is by what the server sends, not by a version read: certification renders only when the response carries it (only 5.4 does), and following runs only when restartJobId is present. A registration_info gate on the deploy path would hide both from roles with an operations allowlist, which Harper denies that operation (AGENTS.md, "Browse — removing an attribute").

❓ Your call: following the job changes how 4.6–5.3 clusters behave, as #1785 asks ("Following the job applies to older versions too"): the deploy now waits for the plain rolling restart, fails if its job errors, and warns about a node the job could not reach. The alternative is to follow only when the response carries certification (5.4+), which leaves older clusters exactly as they are today and keeps their false successes.

❓ Your call: I couldn't run a two-node 5.4 cluster, so the 5.4 shapes are pinned by fixtures built from Harper's source, not by a live rollout. The job-locality reasoning for direct cluster connections is also untested live.

⚖️ Alternatives

🔧 Changes

Also fixed: a CSV import whose load job Harper refused with its permission report showed [object Object]; it now shows the report's text, and importData.ts reads the message the same way. The type change above forced it.

✅ Verification

  • Unit and component tests, with fixtures built from the Harper lines cited above: restartJob.test.ts (both shapes, unconfirmed peers, malformed rows) and its follower tests (back-off, not-found grace and recovery, 400/403, timeout, abort before and during a poll); deployComponent.test.ts (no job means no poll and no timer; success waits for the job; a failed job rejects without deploying again; unknown resolves as unknown; streamed deploys follow the same way); deployOutcome.test.ts (the same decision off the streamed and buffered transports; warnings collected); DeployProgress.test.tsx; useDeploymentStream.test.ts; reportDeployHealth.test.ts; getJob.test.ts; redactRelayedMessage.test.ts.
  • Mutation-checked the new guards: disabling the rollout-failure throw, the unconfirmed-peer read, the 400/403 stop, the not-found grace, the in-flight abort recheck, the unknown-rollout branch in the reporter, or the Datadog redaction entry each turns a test red.
  • Gates, Node 24.21.0: npx vitest run (403 files, 3,777 tests, exit 0; the pre-commit hook ran it again on each commit), npx tsc -b (exit 0), npx oxlint --format stylish . (exit 0), npx dprint check (exit 0).
  • End to end: not observable here. There is no 5.4 multi-node rig, and the dev server's port was shared with other sessions; nothing exercises a real rollout or CM-versus-direct job locality.
  • Cross-model review: two rounds, codex (graded) and gemini. Round 1's two majors and five minors are fixed in the second commit; round 2 resolved all of them and left one minor (an unused DeployToastOptions export, un-exported in the Stage a release now and activate it later #1787 PR) and a comment nit. The Cursor legs could not run (the 1Password SSH agent refused their git fetch), and the Harper domain leg failed authentication.

Closes #1785

🤖 Generated with Claude Code

Related PRs: #1824 overlaps (base of this stack), #1841 overlaps (wraps onDeployComponentSubmit; same deploy error path), #1806 overlaps (adds another relayed error type to the same Datadog list)

Complexity: complicated

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

Review-Attention: study ~12m (raised: degraded review) @ 3b75aa0

dawsontoth and others added 2 commits October 11, 2026 02:11
…certification

A rolling deploy answers once the node that received it is done and hands the
other nodes to a restart_service job (restartJobId). Studio never followed it,
so a node that rejected the release read as a successful deploy. The deploy
mutation now polls get_job until the job ends: a failed job rejects with
DeployRolloutError naming each node, and a job Studio cannot follow resolves
as unfollowable, which the callers never report as success.

Also reads Harper 5.4's canary certification on a deploy's result and its
rejection decision on a failure, keeps the deploy's streamed warning events,
and links the progress panel to the deployment's record.

Refs #1785

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

A finished restart job with a result in neither shape Harper writes is
now unknown rather than complete. The buffered redeploy and import paths
report through the same rollout policy as the streamed one, so a node the
legacy job could not reach, or a rollout Studio could not follow, is a
warning there too, and their toasts link the deployment's record. Following
the job aborts promptly when its signal fires mid-poll, and an unknown
rollout settles the progress panel as inconclusive, not success.

Refs #1785

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 introduces comprehensive tracking and UI visualization for application deployment rollouts and canary certifications. It updates the deployment flow to follow rolling restart jobs, parse streamed warnings, and display detailed per-node outcomes or canary rejection decisions, backed by new unit tests and updated error redaction. Feedback on the implementation highlights a potential bug in the polling loop of followRestartJob where transient network or server errors do not reset the missingSince tracker, which could prematurely trigger a 'not-found' state. It is recommended to reset this tracker in the catch block and bound consecutive failures to handle persistent issues gracefully.

Comment on lines +168 to +174
} catch (error) {
signal?.throwIfAborted();
// Anything else — a dropped connection, a 5xx while that node's own workers restart — passes.
if (isDeterministicRejection(error)) {
return unfollowable('refused');
}
}

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.

medium

During rolling restarts, transient network errors or server-side 5xx errors (e.g., 502 Bad Gateway or 503 Service Unavailable) are common as nodes go up and down. Currently, if a non-deterministic error is caught, the loop continues but missingSince is not reset. This means the time spent during network/server downtime still counts towards the NOT_FOUND_GRACE_MS (20 seconds) limit. If the downtime exceeds 20 seconds, the very next successful poll that returns undefined (not found) will immediately trigger a premature unfollowable('not-found') state, even though we haven't actually had 20 seconds of successful "not found" responses.

Resetting missingSince to undefined in the catch block for non-deterministic errors ensures that the grace period only accumulates during consecutive successful polls where the job is genuinely missing.

Additionally, when implementing polling mechanisms that tolerate transient network or API errors, we should bound the number of consecutive allowed failures (e.g., to 5) so that persistent issues (such as revoked tokens or unreachable servers) are surfaced quickly rather than waiting for the entire timeout period.

Suggested change
} catch (error) {
signal?.throwIfAborted();
// Anything else — a dropped connection, a 5xx while that node's own workers restart — passes.
if (isDeterministicRejection(error)) {
return unfollowable('refused');
}
}
} catch (error) {
signal?.throwIfAborted();
missingSince = undefined;
consecutiveFailures++;
if (consecutiveFailures > 5) {
throw error;
}
if (isDeterministicRejection(error)) {
return unfollowable('refused');
}
}
References
  1. When implementing polling mechanisms that tolerate transient network or API errors, bound the number of consecutive allowed failures (e.g., to 5) so that persistent issues (such as revoked tokens or unreachable servers) are surfaced quickly rather than waiting for the entire timeout period.

@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 68.2% 10354 / 15181
🔵 Statements 68.35% 11043 / 16155
🔵 Functions 61.58% 2671 / 4337
🔵 Branches 63.49% 7982 / 12571
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/instance/applications/components/DeployProgress/DeployProgress.tsx 84.21% 75.64% 83.33% 84.21% 56, 103-135, 281
src/features/instance/applications/components/DeployProgress/describeDeployOutcome.ts 75% 65.21% 100% 75% 32, 63-67, 79, 83
src/features/instance/applications/components/DeployProgress/useDeploymentStream.ts 76.31% 66.66% 87.5% 77.14% 83, 89, 95-101, 105-110, 122, 142
src/features/instance/applications/hooks/useDeploymentRecordAction.ts 16.66% 0% 25% 16.66% 14-19
src/features/instance/applications/modals/reportDeployHealth.ts 81.81% 69.23% 100% 80.95% 61-65, 70-89
src/integrations/api/instance/applications/deployComponent.ts 94.44% 100% 75% 94.44% 104-106
src/integrations/api/instance/applications/deployComponentStream.ts 83.33% 61.9% 100% 83.33% 65-69
src/integrations/api/instance/applications/deployOutcome.ts 100% 92.3% 100% 100%
src/integrations/api/instance/applications/restartJob.ts 100% 96.61% 100% 100%
src/integrations/api/instance/database/getJob.ts 100% 93.75% 100% 100%
src/integrations/api/instance/database/importData.ts 0% 0% 0% 0% 28-105
src/integrations/api/instance/deployments/getDeployment.ts 18.18% 40% 28.57% 18.18% 18-22, 28, 62-78
src/integrations/api/sse/errors.ts 100% 100% 100% 100%
src/integrations/api/sse/streamOperation.ts 77.77% 75% 55.55% 78.68% 54, 128, 163-182
src/integrations/datadog/redactRelayedMessage.ts 93.75% 92.85% 100% 93.75% 69
Generated in workflow #2106 for commit 3b75aa0 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.

1 participant