Skip to content

feat(console): poll-immune in-flight guard for start/stop (option A) - #44

Merged
brettchien merged 1 commit into
mainfrom
feat/console-scale-inflight-guard
Aug 14, 2026
Merged

feat(console): poll-immune in-flight guard for start/stop (option A)#44
brettchien merged 1 commit into
mainfrom
feat/console-scale-inflight-guard

Conversation

@brettchien

Copy link
Copy Markdown
Contributor

Follow-on to #39. Brett asked whether the start/stop button has debounce — it didn't have a real one. This implements option A: move the in-flight guard off the button DOM node into module state, so it's immune to the 5s roster poll's re-render.

The gap (#39)

scale() set btn.disabled = true, but that lived on a specific DOM node. The 5s tick() poll re-renders the whole roster (innerHTML), replacing the disabled button with a fresh enabled one mid-action. The underlying deploy_scale is idempotent (UpdateService to the same count = no-op), so it was harmless but not an actual guard.

Fix

  • scaling: Map<deploymentKey, targetDesired> in module state. A row's action button renders disabled whenever its key is in the set — so a poll re-render preserves the disabled state (poll-immune).
  • The key clears when the roster observes the target desiredCount (the action landed), or via a 15s safety timeout so a never-observed flip can't wedge a button.
  • Re-entry refused while a key is in flight.
  • Instant feedback: repaintRoster() overlays the pending set on the last poll's data without waiting for a fetch.

Changes

  • render.tsdeploymentKey(d); rosterHtml/renderRoster take an optional pending set (defaults empty); a pending row renders a disabled placeholder with no data-action, so even a stray click no-ops.
  • main.tsscaling / scaleTimers maps, prunePending() on each tick, repaintRoster(); scale() sets the guard, awaits, then prunes via tick() (or clears on error).
  • tests — pending → disabled placeholder; siblings stay live; deploymentKey.

Console-only, no Rust touched.

Verification

  • tsc --noEmit, 38 vitest, vite build (green locally)
  • CI: bundle-macos (desktop.yml) triggers on console/**.
  • ⚠️ Live click-test on the macOS app (next nightly).

🤖 Generated with Claude Code

The #39 disable lived on the button DOM node, so the 5s roster poll could
re-render and hand back a fresh enabled button mid-action (the underlying
deploy_scale is idempotent, so it was harmless but not a real guard).

Move the guard to module state: `scaling` maps deploymentKey → the desiredCount
we're driving toward. The action button renders disabled whenever its key is in
the set, so a poll re-render preserves the disabled state. The key clears when
the roster observes the target count (poll-immune), or by a 15s safety timeout
so a never-observed flip can't wedge a button. Re-entry is refused while a key
is in flight.

- render.ts: `deploymentKey(d)`; rosterHtml/renderRoster take an optional
  `pending` set; a pending row renders a disabled `…` placeholder (no
  data-action, so even a stray click no-ops).
- main.ts: `scaling`/`scaleTimers` maps, prunePending on each tick, repaint for
  instant feedback; scale() sets the guard, awaits, prunes via tick() or errors
  out clearing it.
- tests: pending → disabled placeholder; others stay live; deploymentKey.

Verified: tsc + 38 vitest + vite build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brettchien
brettchien force-pushed the feat/console-scale-inflight-guard branch from 4f1176c to 8fd7ccc Compare August 14, 2026 06:37
@brettchien
brettchien merged commit 014a99d into main Aug 14, 2026
2 checks passed
@brettchien
brettchien deleted the feat/console-scale-inflight-guard branch August 14, 2026 06:44
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