Skip to content

feat(config): fan cluster config saves out with replicated set_configuration on Harper 5.2+ - #1861

Merged
dawsontoth merged 5 commits into
stagefrom
claude/1339-replicated-set-configuration
Oct 12, 2026
Merged

dawsontoth merged 5 commits into
stagefrom
claude/1339-replicated-set-configuration

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

A beaver sending one blueprint to every lodge while lanterns relight one by one

📦 This PR now carries #1867 too

Rebased into 5 linear, signed commits on current stage, with no merge commits and no conflicts: #1861 (fan cluster config saves out with replicated set_configuration on Harper 5.2+, Closes #1339) and #1867 (Restart Cluster reports a failed cluster load instead of staying disabled, and Cancel during an instance check is honored, #1853). Both PRs' changes are identical to what was reviewed. Gate on the final tree: tsc -b, oxlint, dprint check, vitest 481/481 files. Overlaps #1899, which reworks the same restart loops; whichever lands second rebases.


⊙ Problem

Saving the config editor on a cluster writes set_configuration to every RUNNING instance from the browser, one at a time, right before restarting it. Instances Studio does not restart (STOPPED, or failing their health check) never get the write and silently keep the old config. Harper can now fan the write out itself with replicated: true (HarperFast/harper#1556). That landed in 5.2.0, not "v5+" as the issue says: it is in no 5.0.x or 5.1.x tag. The write still applies nothing until each node restarts, so the one-at-a-time restart roll has to stay.

❓ Your call: is it worth keeping a second code path for this? It saves N−1 browser writes and reaches members the roll skips. The cost is that, once the write has fanned out, a Cancel leaves the new config saved but not applied on the instances that were not restarted. I think it is worth it, because the fan-out only happens after one instance has come back up on the new config. Reverting is a one-line gate change (shouldFanOutSetConfiguration).

💡 Solution

On a cluster where every live instance reports Harper ≥ 5.2.0 and at least one other live member exists, the roll changes in one place:

  1. The first instance is written and restarted exactly as before. It is the canary.
  2. Once it is back up on the new config, Studio sends one set_configuration with replicated: true through that instance's client. This happens even when it was the last instance to restart, so live members the roll skips still get the write.
  3. A later instance skips its own write only when the fan-out's replicated[] acknowledged that exact node. Fabric names each Harper node after its CM replicationFqdn (host-manager sets NODE_HOSTNAME and REPLICATION_HOSTNAME to it). Each successful entry carries the peer's own message plus the node harper-pro's replicateOperation attaches.
  4. Restarts stay strictly sequential, using the same restart_service call and recovery wait as before.

Every other case keeps the per-instance write: pre-5.2, mixed-version, or unknown-version clusters; per-instance views; local Studio; a rejected fan-out (the instance client already turns any failed peer into a rejection); a response with no usable replicated[]; and a target with no replicationFqdn.

⚠️ Look hardest: the acknowledgement check. It is what lets an instance restart without Studio writing to it. It trusts only a non-empty message string plus a non-empty node name, compared case-insensitively against replicationFqdn. The wire shape is traced from source (harper-pro replication/replicator.ts:897-941, harper server/serverHelpers/serverUtilities.ts:163-165, host-manager src/templates/compose.yaml:44-45), not observed on a live 5.2 cluster.

Also fixed (same hook):

⚖️ Alternatives

❓ Your call: the planning round (codex) returned better-alternative-exists, and I adopted it. My first plan let later instances skip their write when the number of acknowledgements covered the remaining targets. That is unsound: the origin's replication peers are not CM's restart targets, so a stale peer can stand in for a missing one. The shipped design matches by node identity, per target.

  • Fan out first, then roll restarts only, the smallest change. Rejected: the new config would be saved to every node before any node had shown it can boot with it. A Cancel after the first instance fails to come back would then leave that config pending everywhere. Harper's own comment names this hazard (harper-pro#558, config/configUtils.ts:1320-1324).
  • Let Harper roll the restarts too (restart_service with replicated: true). Rejected: it also writes everywhere before any restart. It runs as a job Studio does not poll, which would drop per-instance progress and Cancel. And it is no stronger at reporting a peer it could not reach (bin/restart.ts:360-364).
  • A Harper-side staged config with activate/rollback would give an exact canary without the duplicate origin write. That needs a server release, so it is out of scope.

🔧 Changes

✅ Verification

End-to-end route: not verified against a live Harper 5.2+ cluster. No cluster harness is available here, and port 5173 belongs to another session. The Harper side is traced from source, cited above. Mechanism-level evidence:

Cross-model review: three rounds (codex graded + gemini each round), after a planning round. Cursor legs could not run, because the 1Password SSH agent refused to sign their git fetch. The Harper-domain adjudicator could not run either: its Claude OAuth session had expired. So outside findings were triaged by hand. I rejected gemini's claim that wasAReleasedBeforeB is a strict comparison: it returns b >= a, and a test pins exactly 5.2.0. Its comment nits pointed at pre-existing comments that only moved under the new try.

gemini-code-assist feedback: took its one comment in 3581be6, so a cancel now breaks out of the update loop rather than continue-ing through the remaining targets. The flag never resets, so behavior is unchanged and the cancellation tests still pass. No further review round ran.

Closes #1339

🤖 Generated with Claude Code

🤖 Generated by Claude Code (Anthropic Claude); posted via @dawsontoth.

Related PRs: #1867 overlaps (stacked on this branch; replaces its target planning with a shared helper), #1899 overlaps (reworks the same config-update loop in useRollingConfigUpdate.tsx)
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=3; full=1 @ 3581be6

Review-Attention: study ~20m (critical: replication.ts; raised: degraded review) @ 3581be6

@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 optimizes the rolling configuration update process for Harper clusters (version 5.2.0+). Instead of writing the configuration to each instance individually, it writes and restarts a canary instance, then fans out the configuration write to the rest of the cluster via replication. Subsequent instances skip their local configuration write if the replication fan-out successfully acknowledged them. The PR also adds comprehensive unit tests and design documentation. Feedback on the changes suggests a minor optimization in the update loop to break early when the operation is canceled.

Comment thread src/hooks/useRollingConfigUpdate.tsx
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 74.05% 12304 / 16614
🔵 Statements 74.12% 13100 / 17673
🔵 Functions 68.66% 3263 / 4752
🔵 Branches 69.55% 9660 / 13888
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/hooks/getClusterRestartTargets.ts 100% 100% 100% 100%
src/hooks/useApplyLicensesClick.tsx 81.08% 47.61% 50% 80.55% 87-94, 114, 125-140
src/hooks/useRestartClusterClick.tsx 90.19% 88.88% 42.85% 90% 32, 68, 146, 157, 179
src/hooks/useRollingConfigUpdate.tsx 93.65% 97.43% 60% 93.54% 103, 193, 204, 227
src/integrations/api/replication.ts 100% 100% 100% 100%
src/integrations/api/instance/status/setConfiguration.ts 71.42% 100% 75% 66.66% 36-40
Generated in workflow #2288 for commit 3afc3a9 by the Vitest Coverage Report Action

dawsontoth and others added 5 commits October 12, 2026 17:18
…uration on Harper 5.2+

Saving the config editor on a cluster wrote set_configuration to every
RUNNING instance from the browser, one at a time, before restarting it,
and never wrote to members it did not restart. Harper 5.2.0 added
`replicated: true` to set_configuration (harper#1556), which applies the
write on every replication peer in one call. It does not apply the
config: each node still needs its restart, so the one-at-a-time roll
stays.

On a cluster with more than one live member, all reporting 5.2.0 or
later, the first instance is still written and restarted on its own.
Once it is back up on the new config, the write fans out from it with
`replicated: true` -- also when it was the last instance to restart, so
STOPPED or failed members still get it. A later instance skips its own
write only when the response acknowledged that exact node: a success
entry carrying the peer's `message` string and a `node` equal to the
instance's replicationFqdn (the name Fabric gives each Harper node).
Pre-5.2, mixed-version and unknown-version clusters, per-instance views,
a rejected fan-out, and any unacknowledged target keep the per-instance
write.

After a fan-out, the Cancel and partial-failure toasts say instances that
were not restarted may already have the new config saved.

Also: a failure loading the cluster's instances left Save disabled and
the loading toast spinning, with an unhandled rejection; it now reports
that nothing changed and clears the pending state. A cluster with no
instances reported Success; it now reports that none were found.

Closes #1339

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kipping each target

Cancellation never unsets, so every remaining iteration only re-checked the flag.

Refs #1339

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…of staying disabled

useRestartClusterClick awaited getClusterInfo, and built the per-instance
clients, outside any try. When either failed, the click handler rejected
unhandled: the Restart button stayed disabled until remount, the
"Restarting" toast spun until its 60s duration ran out, and the rejection
reached the console as an unhandled one.

Mirror useRollingConfigUpdate's handling from #1861: load the restart
targets inside a try, end the restart toast with "Could not load the
cluster's instances, so nothing was restarted.", and clear the pending
state in a finally.

Both hooks built their targets the same way (fetch the cluster, check
Fabric Connect, keep RUNNING instances, build a client each, reverse), so
that now lives in one getClusterRestartTargets helper.

Also stop reporting an empty cluster as "Cluster fully restarted!", the
same zero-instance fix #1861 made to the config update.

Closes #1853

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The restart, config-update and apply-licenses loops only looked at their
Cancel flag at the top of each iteration. A Cancel clicked while an
instance's availability check was in flight was therefore ignored for that
instance: the restart, the config write, or the license install went out
anyway. Recheck the flag once the check returns, before the first request
that changes the instance.

Also stop telling the user no instance was RUNNING when every RUNNING
instance failed to restart. Both cluster hooks keyed that message on
nothing having restarted; key it on there being no RUNNING target, so a
cluster whose restarts all failed reads "Only 0 RUNNING instances were
restarted of N".

Found by the pre-push cross-model review of the previous commit; both
predate it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s during the check

Cancellation never unsets, so continuing only re-checked the flag on every remaining target.

Refs #1853

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the claude/1339-replicated-set-configuration branch from 3581be6 to 411e292 Compare October 12, 2026 21:19
@dawsontoth
dawsontoth marked this pull request as ready for review October 12, 2026 21:23
@dawsontoth
dawsontoth requested a review from a team as a code owner October 12, 2026 21:23
@dawsontoth
dawsontoth merged commit c390461 into stage Oct 12, 2026
8 checks passed
@dawsontoth
dawsontoth deleted the claude/1339-replicated-set-configuration branch October 12, 2026 21:24
dawsontoth added a commit that referenced this pull request Oct 12, 2026
…of staying disabled

useRestartClusterClick awaited getClusterInfo, and built the per-instance
clients, outside any try. When either failed, the click handler rejected
unhandled: the Restart button stayed disabled until remount, the
"Restarting" toast spun until its 60s duration ran out, and the rejection
reached the console as an unhandled one.

Mirror useRollingConfigUpdate's handling from #1861: load the restart
targets inside a try, end the restart toast with "Could not load the
cluster's instances, so nothing was restarted.", and clear the pending
state in a finally.

Both hooks built their targets the same way (fetch the cluster, check
Fabric Connect, keep RUNNING instances, build a client each, reverse), so
that now lives in one getClusterRestartTargets helper.

Also stop reporting an empty cluster as "Cluster fully restarted!", the
same zero-instance fix #1861 made to the config update.

Closes #1853

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dawsontoth added a commit that referenced this pull request Oct 13, 2026
…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>
dawsontoth added a commit that referenced this pull request Oct 13, 2026
…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>
dawsontoth added a commit that referenced this pull request Oct 13, 2026
…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>
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.

Set Configuration can be replicated in v5+

1 participant