Skip to content

Stop a failed-closed isolated release from using up a threads.maxIsolated slot - #3607

Merged
kriszyp merged 8 commits into
mainfrom
fix/isolated-admission-shared-set
Oct 27, 2026
Merged

kriszyp merged 8 commits into
mainfrom
fix/isolated-admission-shared-set

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 26, 2026 •

Copy link
Copy Markdown
Member
The author sets a cracked red-tagged crate aside and waves a green crate into the only worker booth

⊙ Problem

With threads.maxIsolated: 1, an isolated application whose first release fails its canary blocks every other isolated deploy with a 409 until it is redeployed or dropped. Deploy admission counted every isolated application on disk; the reconcile that starts workers skips a failed-closed release. They also disagreed when boot order would not admit an application on disk: it deployed, then nothing started it.

🤔 Is the requirement right?

Yes. Admission now follows boot order, so some deploys that used to go live with no worker get a 409. Which application a scoped reconcile should prefer stays open.

💡 Solution

⚖️ Alternatives

  • Filter failed-closed releases from the old count — keeps two computations that still disagree about present applications.
  • Let main answer admission — main's cached config can lag, and refreshing it there can rewrite the config file.
  • Delete a failed first release — certification keeps it live and failed closed on purpose.

🤔 A deploy on a worker thread reads refusals from disk only

The operations API runs on main, which sees every refusal. A worker's server.operation deploy misses refusals main holds only in memory. This can only refuse more, never admit what the reconcile refuses; closing this gap needs a second field on the topology reply.

🤔 An isolated deploy reads refusals twice

Kept: the pre-check refuses before staging; the locked check is authoritative. Cost: one directory read per deploy.

🔧 Changes

✅ Verification

  • New maxIsolated: 1 integration suite: after A's first release fails closed, B deploys and serves through its mirror. A's fix gets 409 while B holds the place, and is certified once B is dropped. On base, B's deploy gets the 409. Without the new admission call, the same-ID activation answers 200 uncertified.
  • Gates: test:unit:main 8323 passing; test:unit:resources 4898 passing; test:integration:all 2776 passing (four ollama-backend failures: no local models).
  • PR QA: worker-thread deploy and restart after a failed first release — queued @ e50e7f8, qa-pr-harper-3607-e50e7f8; later commits touch no path it reaches.
  • Docs: queued docs-harper-3607 for reference/components/applications.md.
  • Late Grok findings below predate the occupancy, 503 and comment fixes.
Unit test cases
  • unitTests/server/threads/isolatedApplications.test.js: running ones keep their place; config order up to the budget; unreadable refusals admit nothing new; a failed first release frees its place; a candidate's own refused release is ignored but a full budget still refuses it; two present with a budget of one; placement of a new integer-like name, a new name and a flip to isolated; occupancy counts every running worker unless its refusal withdrew the slot, and counts an application turned shared; a budget of zero.
  • unitTests/components/releaseCertification.test.js: only a rejected release with nothing to restore reports slotWithdrawn.
  • unitTests/components/deployCertification.test.js: re-arming a rejected live release calls admitIsolation with no descriptor, arms nothing when refused, and leaves the record rejected.
  • integrationTests/components/isolated-application-lifecycle.test.ts: the existing suite now shares its helpers with the new one.

🤖 Generated by Claude Code (Claude Opus 5.5); posted via @kriszyp.

Release target: main — release certification, which leaves a failed first release in place, exists only on main
Related PRs: #3505 overlaps (this branch is rebased on it; both edit the admitIsolation callback), 1 other independent
Complexity: complicated

Deferred findings

  • 3af96fffdf79ff4d minor concurrency — components/operations.ts — Isolated admission is per-component locked, so concurrent deploys of different apps can both pass — NOT FILED
  • fb2f1c1838d28ea0 minor correctness — components/operations.ts — Worker-originated admission omits main's memory-only release refusals — NOT FILED
  • 7a35bd5966585ca9 nit correctness — server/threads/isolatedApplications.ts — Disk refusal with slotWithdrawn false masks main's withdrawn in-memory entry — NOT FILED
  • 5ace95afe0f4a66d nit reliability — server/threads/isolatedApplications.ts — Running refused or no-longer-isolated workers hold no selector place; budget checked in two parts — NOT FILED
  • d1b1df8a04d8df60 nit performance — components/operations.ts — Isolated deploy admission awaits a staging-root refusal scan, more than once per deploy — NOT FILED
  • 297fa6f6c1579f90 nit performance — server/threads/isolatedApplications.ts — admitted.includes in loop is quadratic — NOT FILED
  • 1168f9f920830637 nit maintainability — components/operations.ts — Isolation admission errors use banned handleHDBError instead of ClientError/ServerError — NOT FILED

Late Grok findings

4 surviving (highest major) — Grok reviewed 5651bea
  • major — server/threads/isolatedApplications.ts:136 — capacity counting drops every running worker whose release is failed-closed, including one that is failed-closed only because its certification record became unreadable (components/releaseCertification.ts:225-231). Nothing queues that worker's withdrawal, so it keeps serving while B is admitted un
  • minor — components/operations.ts:588 — the new failedClosedApplications() failure branch uses legacy handleHDBError. AGENTS.md forbids that helper, and HdbError exposes the raw filesystem message instead of the deployment-context text; should be new ServerError(msg, 503)
  • nit — components/operations.ts:571 — an isolated deploy scans .deploy-staging for refusals twice: once in the pre-check at :607, and again when admitIsolation re-runs admission under the preparation lock (components/Application.ts:5287). The cost lands on the deploy path, not a request path
  • nit — integrationTests/components/isolated-application-lifecycle.test.ts:85 — added comments that narrate adjacent code, here and at components/Application.ts:5286 and unitTests/server/threads/isolatedApplications.test.js:125-128. Also, the comment at unitTests/components/deployCertification.test.js:307 says "join", but the code it describes is the optional-join fallbac

Not standing: 3 separate-issue. Grok ran 24m (24m from launch to report); the fold adjudication (cursor-grok@5651beaf1202-6452690e, claude) took 1m.

Origin — the dispatch brief this PR was written from

Isolated-application admission is computed in two places that disagree: assertIsolationAdmission (components/operations.ts) counts a failed-closed first release (directory and config entry left on disk) against maxIsolated, while admittedIsolatedApplications in server/threads/socketRouter.ts excludes it. With maxIsolated: 1, a failed first deploy of app A makes a deploy of app B return 409 until A is redeployed or dropped. Export one admitted-set function (socketRouter.ts or isolatedApplications.ts), failed-closed excluded, and have assertIsolationAdmission use it. Do not change scoped reconcileIsolatedWorkers ordering semantics (boot order vs requested app) in this PR; that is an open design question.

Acceptance

Test: maxIsolated: 1, deploy app A with a failing first release, then deploy app B -> B succeeds; existing isolation tests green. Show each new test failing on origin/main sources (forced rebuild) before the fix.

Dispatch: task harper-isolation-admission-shared-set · queued by finding-triage/wave-40 · ran by claude/opus/xhigh · worker kzyp-xps-1

Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; declined=muse; rounds=8; full=2 @ ff2525b

Review-Attention: study ~15m (hot: isolatedApplications.ts, socketRouter.ts; decisions: slot-withdrawal-source, admission-two-checks, recertify-admits, do-less-alternative) @ ff2525b

@kriszyp kriszyp added this to the v5.4 milestone Oct 26, 2026

@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 refactors and improves the admission logic for isolated applications under the threads.maxIsolated budget, ensuring that only applications with a dedicated worker are counted. It introduces helper functions like admittedIsolatedApplications, isolatedApplicationAdmissionRefusal, and failedClosedApplications to centralize this logic, and updates deployment and reconciliation processes. Feedback on the changes identifies an issue where in-memory refusals are instantiated as plain Error objects instead of RejectedReleaseError, which prevents them from being recognized as having their slots withdrawn and incorrectly charges them against the budget.

Comment thread server/threads/isolatedApplications.ts Resolved
kriszyp and others added 8 commits October 26, 2026 19:15
…led-closed release holds no place in threads.maxIsolated

Deploy admission (`assertIsolationAdmission`) counted every present isolated
application against `threads.maxIsolated`, including a first release whose
canary rejected it and which stays on disk failed closed. The reconcile skips
such a release, so with `maxIsolated: 1` a failed first deploy of one
application made every other isolated deploy return 409.

The selector boot and the reconcile use (`admittedIsolatedApplications`) and the
failed-closed reader move to `server/threads/isolatedApplications.ts`;
socketRouter keeps its reporting. Deploy admission asks the same selector about
the candidate, placed at its own root-config entry and with its own refused
release ignored, and also counts the workers a scoped reconcile leaves running.
An already-live activation that re-arms a refused release is admitted first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-isolation-admission-shared-set
Admission left every failed-closed application out of the occupancy count, but
only a canary's decided rejection withdraws its worker. A running application
whose certification record becomes unreadable is refused and keeps running, so
it still counts. `RejectedReleaseError` now says which refusals are decided.
The new 503 branch throws `ServerError`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-isolation-admission-shared-set
A rejected release with a predecessor is restored, and its worker keeps
running through the restore, so it still holds its place. Only a rejection
with no earlier release stays live failed closed and has its dedicated worker
withdrawn; `RejectedReleaseError.slotWithdrawn` now says exactly that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-isolation-admission-shared-set
…e rebase

server/DESIGN.md reached its line budget on main, so the note on the shared
selector and the occupancy count moves into the two components/DESIGN.md
sentences that already describe the reconcile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-isolation-admission-shared-set
…e is certified again

Admitting the already-live re-arm path through admitIsolation without a
descriptor now follows the branch for an activation that publishes no routing:
the grant is checked against the current root-config entry, as it is for a
payload artifact that set none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-isolation-admission-shared-set
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-isolation-admission-shared-set
Main keeps a refusal in memory when it cannot write the rejection record, and
reads it only while that release is still live, failed closed; deciding it
already queued the withdrawal of the application's dedicated worker. It now
reads as a `RejectedReleaseError` with `slotWithdrawn`, so admission does not
count that worker, and its message matches a recorded refusal's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-isolation-admission-shared-set
…cation is isolated

Main withdraws a refused release's dedicated worker only when the release is
isolated; an application turned shared keeps its old worker until the pool
restarts. Admission now exempts a refused running worker only if its
application is still isolated in config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-isolation-admission-shared-set
@kriszyp
kriszyp force-pushed the fix/isolated-admission-shared-set branch from 364821e to ff2525b Compare October 27, 2026 01:35
@kriszyp
kriszyp marked this pull request as ready for review October 27, 2026 02:16
@kriszyp
kriszyp merged commit b35165b into main Oct 27, 2026
94 of 101 checks passed
@kriszyp
kriszyp deleted the fix/isolated-admission-shared-set branch October 27, 2026 02:17
Ethan-Arrowood pushed a commit to HarperFast/documentation that referenced this pull request Nov 3, 2026
…ed-closed releases (#759)

* Document that isolated admission follows startup order and skips failed-closed releases

Docs for HarperFast/harper#3607: threads.maxIsolated counts only isolated
applications that get a dedicated worker (running first, then root-config
order); a failed-closed live release holds no place. deploy_component answers
409 when the application would get no worker in that order, including a
refused release activated again by deployment_id, and 503 when refusals
cannot be read.

Dispatch-Task: docs-harper-3607
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Badge the v5.4.0 maxIsolated change, count retained workers, note the 503 on reactivation

Dispatch-Task: docs-harper-3607
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Group the running-worker sentences and split the maxIsolated refusal sentence

Dispatch-Task: docs-harper-3607
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Nov 5, 2026
…ed-closed releases (HarperFast/documentation#759)

* Document that isolated admission follows startup order and skips failed-closed releases

Docs for #3607: threads.maxIsolated counts only isolated
applications that get a dedicated worker (running first, then root-config
order); a failed-closed live release holds no place. deploy_component answers
409 when the application would get no worker in that order, including a
refused release activated again by deployment_id, and 503 when refusals
cannot be read.

Dispatch-Task: docs-harper-3607
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Badge the v5.4.0 maxIsolated change, count retained workers, note the 503 on reactivation

Dispatch-Task: docs-harper-3607
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Group the running-worker sentences and split the maxIsolated refusal sentence

Dispatch-Task: docs-harper-3607
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

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.

1 participant