Repository navigation
fix(restart): say a restart Harper accepted is not back yet, never that it failed - #1899
Merged
dawsontoth merged 9 commits intoOct 13, 2026
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces the RestartUnconfirmedError to handle scenarios where a restart request is successfully accepted by Harper, but Studio cannot confirm that the instance has recovered. This prevents these cases from being incorrectly flagged as failures, which could lead to redundant restarts. The changes update the restart logic, rolling configuration updates, and chat tools to handle this error gracefully with clearer user notifications, and include comprehensive unit and integration tests. There are no review comments to assess, and I have no additional feedback to provide.
dawsontoth
added this pull request to stack #1900
October 11, 2026 21:24
This was referenced Oct 12, 2026
Merged
Merged
dawsontoth
force-pushed
the
claude/1895-restart-accepted-not-back
branch
from
October 13, 2026 00:52
06dd402 to
27e170c
Compare
…at it failed restartInstance sent the restart and then watched for the target with user_info, and both steps rejected the same promise, so a target slow to answer again read as a failed restart everywhere: "Failed to restart instance.", "Only N of M instances restarted", and success: false to the chat agent. Each of those invites a second restart of a target that already went down once. Harper's answer to the restart request is its only verdict: `restart` answers before it tears down (bin/restart.ts schedules the teardown 50ms out), and `restart_service` once its job has started (executeJob). So a probe failure after a 2xx now rejects with RestartUnconfirmedError (cause: the last probe error), shown as "Not back yet ... check its status before you restart it again". - The watch keeps going past the usual recovery time (a second probe schedule, no second settle) and calls onSlowToReturn once in between, so the Restart button and both loops say "still waiting" instead of giving up. Every restart mark's TTL comes from the same constants, and the probes skip the gateway replay, so no mark lapses mid-watch. - The cluster restart and rolling config update stop at a member that never comes back instead of restarting the next one, which could leave no member serving, and report the members they left alone. - createApp keeps success: true (the app exists) and restartHTTPService returns copy without the "Error:" prefix telling the agent not to restart again. Both now replace their progress toast on any failure, which used to keep spinning. Closes #1895 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…members too Review of the previous commit: - A Cancel clicked while the loop waited on a member that never came back replaced the "Not back yet, check before you restart it again" toast with "Cancelled", hiding the one warning that matters. The uncertain outcome now wins; Cancelled still shows when nothing is uncertain. - The uncertain-outcome toast counted the members that refused only implicitly. It now says "N instances failed to restart", from one helper both loops share (describeUncertainRollingOutcome). - onSlowToReturn is queued rather than called inline, so a callback that throws cannot cut the watch short or become its cause. Not adopted: counting a 401/403 probe as the target being back. Through central manager's proxy a 403 is CM refusing the caller before it contacts the instance (notAllowed() in HDBInstance.js), so it says nothing about the target; recorded in DESIGN.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hat it never answered Review round 2: - A probe can fail with an answer (a 401/403, a Harper 500), so "hasn't answered since" was not always true. Every surface now says Studio couldn't confirm the target came back, which is what a failed watch proves. The watch itself still counts only a successful user_info as back: through central manager's proxy a 403 is CM refusing the caller (notAllowed() in HDBInstance.js) before it contacts the instance. - A throwing onSlowToReturn is caught and logged instead of escaping as an uncaught microtask exception; the watch carries on either way. - RollingOutcome is no longer exported; it has no consumer outside its module. Not adopted: dismissing the button's loading toast for an unconfirmed restart. It already is: the catch dismisses it before deciding whether to add its own toast. A test now pins that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ever answered Review round 3: DESIGN.md and restartInstance's doc comment still said the target "never answers", which contradicts the rule that only a successful user_info counts as back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efused config-update members Mutation checks after the rebase onto the new restart loops found three guards no test held: the restart request's explicit 60s timeout (the client default is also 60s, so it only shows with that default cleared), RESTART_MARK_TTL_MS on the Restart button and both cluster-loop marks (restoring the old five minutes stayed green, since sleep is mocked), and the rolling config update's failed count. - restartInstance: the request's bound, every probe's timeout and every wait add up to less than RESTART_MARK_TTL_MS. - The Restart button and the cluster loop still hold their marks six minutes in, past the old TTL, while the watch runs. - A config update names a member that refused alongside the one not back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fig fan-out After the rebase onto #1861's rolling config update, the stop rule also decides the fan-out: a canary that is not back never proved it can run the new configuration, so it is never fanned out from, and a stop after the fan-out keeps the note that the members left alone may already have the configuration saved. Both are pinned in useRollingConfigUpdate.test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ay what Cancel left Review round 1 after the port (codex major, kept by the Harper domain adjudicator): the cluster loop waits on a member whose restart got no verdict, but when that watch also ran out it discarded the rejection and restarted the next member. On a two-member cluster, a 504 after the restart ran plus a member that stays down left no member serving, which is exactly what the stop rule exists to prevent. The loop now stops there too, and the report says Studio couldn't confirm that member came back either, instead of claiming it accepted the restart. The adjudicator's nit: a Cancel after a no-verdict member now yields the "Outcome unknown" report, which never said Cancel skipped the rest. Both loops now name the members Cancel left alone in that report. Also from the round: drop the test comments that only narrated their setup, and trim restartInstance's JSDoc to its contract (DESIGN.md carries the reasoning). Not taken: Cancel during set_configuration still restarts that member; that check is #1867's, unchanged here. The watch outliving the progress toasts' durations is not a problem: sonner never starts an auto-close timer for a loading toast (sonner 2.0.8, dist/index.mjs:644). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dawsontoth
force-pushed
the
claude/1895-restart-accepted-not-back
branch
from
October 13, 2026 01:41
27e170c to
3633963
Compare
…onor Cancel mid-write Review round 2 after the port (codex major twice; the Harper domain adjudicator kept both as minor and said the base stack could take them): - An unanswered restart_service moved on to the next member after 3s. If the restart ran onto a configuration that broke the member, the next member got the same configuration. The config update now waits on that member, as the cluster restart does, and stops there if the watch gives up too. An unanswered set_configuration does not wait: nothing restarted that member. - A Cancel clicked while a member's set_configuration was in flight still restarted it. The loop now stops before that restart, and the Cancelled report says the configuration may be pending there, as after a fan-out. - After a Cancel, the "Waiting On Instance" toast that onSlowToReturn brings back offered a second Cancel, as if the first had not landed. Both loops now show it without the action and say no other instance will be restarted or updated. Also drops the test helper comments the round flagged as narration. Rejected on evidence: gemini's "axiosRetry resends the restart POST" (the POST is sent once; axiosRetry wraps only the probes, and #1891's replay never repeats a write), and "a Cancel aborts the watch into RestartUnconfirmedError" (Cancel only sets a flag; no signal reaches the watch). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l was clicked Review round 3 (codex minor, kept by the Harper domain adjudicator): round 2 took the Cancel action off the slow-return toast, but the no-verdict follow-up toast round 2 added to the config update, the cluster loop's one, and both loops' "carry on momentarily" toasts still came back with a Cancel button after Cancel had closed the toast, and promised to carry on for a watch of up to about seven minutes. One helper per loop now builds every progress toast after the first: the usual Cancel action and copy, or, once Cancel was clicked, neither, and a note that no other instance will be restarted or updated. Also drops a comment that only restated its flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
A restart Harper accepted is reported as failed when the target is slow to answer again:
restartInstancesends the restart, then watches for the target withuser_info, and both steps rejected the same promise. So the Restart button toasts "Failed to restart instance.", the cluster restart and rolling config update count the member as not restarted, and the chat tools returnsuccess: false. Each one invites a second restart of a target that already went down once (#1895).💡 Solution
Harper's answer to the restart request is its only verdict on the restart.
restartreturnsRESTART_RESPONSEon the main thread and schedules the teardown 50ms later "so we give the HTTP thread a chance to return a response" (harperbin/restart.ts:116-188; a non-object result is wrapped as{ message }atserver/serverHelpers/serverUtilities.ts:163-165).restart_serviceruns throughexecuteJoband answers{ message: 'Restarting http', job_id }once its job thread has been launched (serverUtilities.ts:590-617,server/jobs/jobRunner.ts:92-94); a bad service is a 400 fromjobs.ts:130-133. So a 2xx means accepted, and the probe that follows only tells "back" from "not back yet". (Read-only harper checkout atv5.3.1-158-g16e5c663c.)restartInstancenow rejects with the restart request's own error when that request failed (refused, or unanswered, which fix(errors): say a write's outcome is unknown when it got no verdict, not that it failed #1897's "outcome unknown" handles), and wraps only a failure after the POST resolved in the newRestartUnconfirmedError(cause: the last probe failure; its message keeps the probe's text soshouldKeepEventfilters it as before).onSlowToReturnonce between them so callers say "Still restarting" instead of giving up. The probes skip the gateway replay, the restart POST gets an explicit 60s timeout, andRESTART_MARK_TTL_MSis derived from the same constants, so no restart mark lapses mid-watch.describeErrormaps the new error to Not back yet: "Harper accepted the restart, but Studio couldn't confirm it came back. It may still be starting, so check its status before you restart it again."shouldToastOwnFailurekeeps the button from adding "Failed to restart" beside it.success: true(their action was carried out) with copy telling the agent not to restart, or create the app, again.Also fixed: both chat tools now replace their loading toast on any failure; before, "Restarting HTTP service..." / "Creating from template..." kept spinning, since a loading toast never dismisses itself.
🔀 Port onto #1861/#1867
#1861 and #1867 replaced the loops this PR edited: both now plan their targets through
getClusterRestartTargets, report a failed cluster load, and honor a Cancel clicked during the instance check; the rolling config update also fansset_configurationout from its first instance on Harper 5.2+. The old loop code is gone; this PR's intent is applied to the new code:useRestartClusterClick.tsx): the stop, failed count, "Waiting On Instance" toast and shared TTL sit inside fix(clusters): Restart Cluster reports a failed cluster load instead of staying disabled #1867's load-failuretryand after its Cancel check, so a failed load still reports that nothing was restarted and a Cancel during the check still stops before the restart.useRollingConfigUpdate.tsx): the stopbreaks before the fan-out block, so a canary that accepted the restart but is not back is never fanned out from: it never proved it can run the new configuration, which is why the fan-out waits for a canary at all. A stop at a later member, after the fan-out, keeps feat(config): fan cluster config saves out with replicated set_configuration on Harper 5.2+ #1861's note that the members left alone may already have the new configuration saved, in the uncertain-outcome toast and in the one that outranks Cancel.*.unconfirmed.test.tsxfiles this PR used to avoid add/add conflicts with them. They run through the realgetClusterRestartTargetsand plan, withrestartInstancemocked to reach each outcome.⚖️ Alternatives
The planning review (codex) returned
better-alternative-exists; its variant was adopted: keep the producer-owned typed error, but stop the rolling loops on it, keep watching inside one deadline, and give the chat tools a non-contradictory result.{ data, answeredAgain: false }): fails open, since any caller not updated, and the mutation's global handler, would report an unconfirmed restart as "restarted!".bin/restart.ts:135-137, is unbounded) without changing what the edge says.🔧 Changes
src/integrations/api/instance/status/restartUnconfirmed.ts(new): the error class, the Not back yet copy, anddescribeUncertainRollingOutcome, which both loops use for their final toast when an outcome is unknown: confirmed count, members that failed, unanswered writes (fix(errors): say a write's outcome is unknown when it got no verdict, not that it failed #1897's sentence), the member not back plus the ones left alone (worded for a no-verdict member too), and the members a Cancel left.src/integrations/api/instance/status/restartInstance.ts: the typed rejection, the two-schedule watch (a callback that throws is logged and the watch carries on), probes without gateway replay, the explicit request timeout, and the derived mark TTL.src/react-query/queryClient.ts:describeErrorandshouldToastOwnFailurerecognise the new error.src/hooks/useRestartInstanceClick.ts: marks with the shared TTL and turns its toast into "Still restarting" halfway through the watch.src/hooks/useRestartClusterClick.tsx: shared TTL for both marks, a "Waiting On Instance i of n" toast, the stop, a failed count, the stop at a no-verdict member whose watch gave up, an uncertain outcome that outranks Cancel and names the members Cancel left, and progress toasts that stop offering Cancel once it was clicked.src/hooks/useRollingConfigUpdate.tsx: the same waiting toast, stop before the fan-out, failed count, wait on a no-verdict restart, and stop if it never answers, Cancel honored between the write and the restart, Cancel precedence and the same progress toasts; it marks nothing, so there is no TTL to change.describeToolError.tsaddstoastToolFailure;restartHTTPService/execute.tsandcreateApp/execute.tsreturnsuccess: truewith no-retry copy for an unconfirmed restart, and replace their progress toast on every failure, including the app-creation one.src/features/instance/operations/index.tsx: the Operations console's restart mark usesRESTART_MARK_TTL_MSinstead of its own 5 minutes, since itswaitUntilAnsweringAfterRestartwatch is now longer; its test's module mock gains the constant.DESIGN.md: a new section recording the verdict rule, the shared deadline, the successful-probe-only rule, the loop stop, the no-verdict stop, Cancel, and how the stop meets the 5.2+ fan-out, and the chat-tool contract.✅ Verification
End-to-end route: integration-style tests through the real
restartInstance, a real instance client (getInstanceClientwith a fake adapter), and the real React Query mutation error handler; the loops are exercised through their real target planning withrestartInstancemocked to reach each outcome. Not run against a live instance.a35e2bed0showed the bug's signature, two toastsError / timeout of 3000ms exceededandError / Failed to restart instance.for a restart Harper answered 200; with the fix it shows oneNot back yettoast and dismisses the loading toast. A second test holds the probe open past the first schedule and checks the mark is held (still held six minutes in, past the old TTL), the button pending, "Still restarting" shown, then success once it answers.restartInstance.test.tsdrives a real instance client: an accepted restart that never comes back rejects withRestartUnconfirmedErrorafter exactly 24 probes on the 10s + 23×15s schedule, a slow target is still watched, the request's own 60s bound plus every probe and every wait fit insideRESTART_MARK_TTL_MS, refused / gateway / no-answer POSTs keep their own error, and RUM filtering is unchanged for wrapped timeouts and network errors.useRestartClusterClick.test.tsx: stops at a member that is not back and names the ones left alone, goes on once a slow member answers (holding its mark and the cluster's past the old five minutes), still carries on past a refusal, names refused members beside the one not back, keeps the warning when Cancel is clicked, and still says Cancelled when nothing was uncertain. From the review rounds: a no-verdict member whose watch also gives up stops the loop, a Cancel after one names the members it left, and no toast after a Cancel offers Cancel again.useRollingConfigUpdate.test.tsx: the same stop, refusal and Cancel cases, plus the port's two, a canary that is not back is never fanned out from and a stop after the fan-out keeps the pending-configuration note, and from the review rounds the stop at a no-verdict restart, no wait after a no-verdict write, Cancel during the write and no second Cancel.restartTools.test.tspins both chat-tool results and toasts;queryClient.test.tspins the shared copy.sleepis mocked; the config update's failed count); each got a test intest(restart): pin …and then went red. Covered: the producer's wrap, timeout, probe replay opt-out, slow callback and its guard, second schedule and TTL budget;describeErrorandshouldToastOwnFailure; the button's TTL and toast; both loops' stop, classification, waiting toast, Cancel precedence, failed count, Cancel count, no-verdict stop and its copy, and progress toasts without Cancel; the cluster loop's two marks; the config update's pending note, wait gate, Cancel-during-write and its note; the shared failed sentence; and the chat tools' branches and toast.npx vitest runexit 0 (522 files, 5033 passed, 1 expected fail, 11 skipped; the pre-commit hook also ran the full suite on every commit),npx tsc -bexit 0,npx oxlint --format stylish .exit 0,npx dprint checkexit 0, all on Node 24.21.0.Stack: based on #1897 (
claude/1890-outcome-unknown-writes) → #1891 → #1882 →stage, all linear on currentstage.🔁 Review after the port
Four rounds, each with codex (graded), gemini and the Harper domain adjudicator, every one
independent=true. The Cursor legs contributed nothing: round 1's grok leg exited 143, and every other Cursor attempt failedno-receiptbecause the 1Password SSH agent refused itsgit fetch; rounds 3 and 4 pruned them as narrow deltas. Round 4 (delta, at the head): codex LGTM, adjudicator Surviving-Findings: none.Taken
restart_service, so a configuration that broke that member could reach the next one. It now waits and stops the same way; an unansweredset_configurationdoes not wait, since nothing restarted that member.set_configurationstill restarted that member. Now stops before the restart, and says the configuration may be pending (❓ above).restartInstance's JSDoc trimmed to its contract, one flag comment removed.Rejected on evidence
dist/index.mjs:644), which the repo already relies on (useRestartInstanceClick.ts). The adjudicator dropped it as factually wrong.??drops the pending-configuration note" (gemini, round 1): the note is appended outside the parenthesised(uncertain?.description ?? …); a test pins it and its mutation went red.holdUntilAnsweringis set only in the catch, for a request that got no verdict.axiosRetryresends the restart POST" (gemini, round 2): the POST is sent once;axiosRetrywraps only the probes, and fix(api): replay a gateway error only for reads, so a write is never sent twice #1891's replay never repeats a write.RestartUnconfirmedError" (gemini, round 2): Cancel only sets a flag; no signal reaches the watch.The harness's "state-trace coverage gap" on the new Cancel count was checked by hand: a member lands in none of restarted / failed / unanswered only when Cancel stopped the loop before its restart (or after its check), and the count is zeroed when the loop stopped at a member that is not back. Both loops' Cancel-count tests pin it, and its mutation went red.
Closes #1895
🤖 Generated with Claude Code (Claude Opus 5.5); posted via @dawsontoth.
Related PRs: #1897 overlaps (this PR's base), #1882 overlaps (base of the stack; shares the restart TTL), #1867 overlaps (merged; reworked both restart loops, ported onto here), #1861 overlaps (merged; reworked useRollingConfigUpdate and added the fan-out the stop now gates)
Complexity: medium
Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; blocked=cursor-grok(failed),cursor-composer(no-receipt),cursor-muse(no-receipt); declined=cursor-kimi; rounds=4; full=1 @ 6cd6fbc
Review-Attention: read ~6m (decisions: canceled-progress-copy, progress-helper-duplicated, cancel-mid-write-stops-before-restart, availability-stop-outranks-cancel, chat-tool-success-true, watch-length-8min, do-less-alternative) @ 6cd6fbc