Context
Resolved ticket #16911 and PR #16912 made the browser cockpit self-redeem the Fleet process bearer from GET /fleet/handshake. A later live occurrence showed the remaining timing failure: both the module-scope redemption and the immediate onStart() retry can finish before Fleet binds, after which the page keeps a bearer-less fail-closed bridge even though the handshake endpoint becomes healthy.
The first draft fix put readiness in devCockpit.mjs and delayed webpack until an authenticated listAgents round-trip succeeded. An operator architecture challenge falsified that prescription: the cockpit shell owns the top-chrome instance switcher and manage-instances surface. Operators must be able to change the Fleet/Memory-Core/Knowledge-Base target while the current target is disconnected. Withholding the shell therefore removes the recovery control needed to escape the failure.
The Problem
The credential bootstrap is exhausted too early, but the shell is not credential-gated:
apps/agentos/app.mjs performs one top-level awaited handshake request before onStart().
- A refusal settles immediately;
onStart() performs one more request back-to-back.
- If both finish before Fleet binds, no later event retries credential acquisition.
- FleetCockpit's liveness cadence re-drives reads through the already bearer-less bridge; it cannot mint custody.
The page must render immediately in a truthful disconnected state. The missing behavior is bounded App-Worker custody healing, not launcher-owned UI admission.
The Architectural Reality
AgentOS.view.Viewport constructs the top toolbar, InstanceSwitcher, keeper-view rail, and Fleet cockpit independently of Fleet connectivity.
ViewportController.switchToProfile() deliberately publishes the chosen profile even bearer-less, mirrors instanceState: 'starting'|'off', and calls FleetCockpit.reconnectFleet().
FleetCockpit already owns per-surface sample / live / stale truth, last-known retention, the spine banner, a Reconnect action, and a 15-second liveness re-drive.
establishFleetSessionCustody() already upgrades a fail-closed bridge through a detached candidate and authenticated viewer proof; it is the custody boundary the delayed handshake result must enter.
- A background heal must never overwrite a later operator switch. The expected published bridge is therefore a compare-and-swap precondition both before redemption continues and before a verified detached candidate is promoted.
- The launcher still owns child supervision, but Fleet readiness is data readiness, not permission to render the recovery shell.
The Fix
- Remove the top-level network await from
app.mjs; direct-browser boot publishes its immediate fail-closed/live bridge and calls Neo.app() without waiting for Fleet.
- After the shell boot call, start one bounded, single-flight handshake-heal window for a bearer-less browser join. Repeated refusals remain
null; the window ends at its deadline and never becomes an unbounded polling owner.
- Stop the heal when the published bridge no longer equals the expected boot bridge. A target change, manual re-wire, or another successful owner wins.
- When redemption succeeds while the expected bridge still owns the slot, pass the pair into
establishFleetSessionCustody(); its authenticated proof promotes the candidate.
- Strengthen detached-candidate promotion with a published-bridge CAS so an operator switch during verification cannot be overwritten after the proof returns.
- Keep launcher ordering, incumbent reuse, wire vocabulary, bearer format, and pane-local state unchanged. Existing cockpit liveness observes the healed bridge and updates each surface honestly.
No new .mjs file is required.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback / Edge Case |
Docs |
Evidence |
| Cockpit shell boot |
app.mjs + Viewport |
call Neo.app() without waiting for Fleet |
disconnected target still renders switch/manage/reconnect controls |
app boot JSDoc |
ordering witness |
| Browser handshake heal |
redeemFleetBearerHandshake.mjs |
bounded retry of the existing exact-origin handshake |
deadline/cancellation → null, no permanent timer |
helper JSDoc |
retry/deadline/cancel matrix |
| Session custody upgrade |
establishFleetSessionCustody() |
detached candidate verifies before promotion |
refused/unreachable candidate preserves published bridge |
custody JSDoc |
existing lifecycle matrix |
| Operator-switch race |
published registryBridge identity |
expected-bridge CAS before retry and promotion |
changed bridge wins; stale heal cannot publish |
custody/app JSDoc |
gated verification falsifier |
| Fleet-backed panes |
existing FleetCockpit routing matrices |
existing sample/live/stale + liveness paths observe bridge replacement |
no bridge keeps honest cold/last-known state |
existing JSDoc |
existing cockpit suites |
Decision Record impact
Aligned with ADR 0019: no new config leaf, env re-read, alias, or runtime mutation. Aligned with the existing connection-profile custody model and #16699 liveness model. No ADR amendment required.
Acceptance Criteria
Out of Scope
- Gating webpack or hiding the cockpit until Fleet is ready.
- New pane-level credential state or per-pane retry loops.
- Changing connection-profile identity, Fleet wire methods, bearer format, CORS, or handshake threat posture.
- Request-time multi-user Fleet ownership under #16168.
- Reworking the existing sample/live/stale view model.
Avoided Traps
- Blank-until-connected: removes the target switcher and manage-instances recovery path.
- Calling data readiness shell readiness: conflates a view's source state with permission to render the application.
- Unbounded App-Worker polling: creates a permanent hidden process supervisor.
- Stale background heal after operator choice: the old default endpoint must never overwrite a newer explicit target.
- Per-pane bearer retries: duplicates credential custody across consumers; panes consume the bridge and render state.
- Reopening #16911: this remains a successor for a later-observed race.
Related
Parent: #14560. Successor to #16911 / PR #16912. Related: #16699. Adjacent future control-plane work: #16168. Draft implementation PR: #17548.
Origin Session ID: f47f948b-743b-4c11-84a8-fa60a567a148
Retrieval Hint: query_raw_memories("Fleet cockpit shell instance switcher bearer handshake bounded heal")
Context
Resolved ticket #16911 and PR #16912 made the browser cockpit self-redeem the Fleet process bearer from
GET /fleet/handshake. A later live occurrence showed the remaining timing failure: both the module-scope redemption and the immediateonStart()retry can finish before Fleet binds, after which the page keeps a bearer-less fail-closed bridge even though the handshake endpoint becomes healthy.The first draft fix put readiness in
devCockpit.mjsand delayed webpack until an authenticatedlistAgentsround-trip succeeded. An operator architecture challenge falsified that prescription: the cockpit shell owns the top-chrome instance switcher and manage-instances surface. Operators must be able to change the Fleet/Memory-Core/Knowledge-Base target while the current target is disconnected. Withholding the shell therefore removes the recovery control needed to escape the failure.The Problem
The credential bootstrap is exhausted too early, but the shell is not credential-gated:
apps/agentos/app.mjsperforms one top-level awaited handshake request beforeonStart().onStart()performs one more request back-to-back.The page must render immediately in a truthful disconnected state. The missing behavior is bounded App-Worker custody healing, not launcher-owned UI admission.
The Architectural Reality
AgentOS.view.Viewportconstructs the top toolbar,InstanceSwitcher, keeper-view rail, and Fleet cockpit independently of Fleet connectivity.ViewportController.switchToProfile()deliberately publishes the chosen profile even bearer-less, mirrorsinstanceState: 'starting'|'off', and callsFleetCockpit.reconnectFleet().FleetCockpitalready owns per-surfacesample/live/staletruth, last-known retention, the spine banner, a Reconnect action, and a 15-second liveness re-drive.establishFleetSessionCustody()already upgrades a fail-closed bridge through a detached candidate and authenticated viewer proof; it is the custody boundary the delayed handshake result must enter.The Fix
app.mjs; direct-browser boot publishes its immediate fail-closed/live bridge and callsNeo.app()without waiting for Fleet.null; the window ends at its deadline and never becomes an unbounded polling owner.establishFleetSessionCustody(); its authenticated proof promotes the candidate.No new
.mjsfile is required.Contract Ledger Matrix
app.mjs+ViewportNeo.app()without waiting for FleetredeemFleetBearerHandshake.mjsnull, no permanent timerestablishFleetSessionCustody()registryBridgeidentityDecision Record impact
Aligned with ADR 0019: no new config leaf, env re-read, alias, or runtime mutation. Aligned with the existing connection-profile custody model and #16699 liveness model. No ADR amendment required.
Acceptance Criteria
Neo.app()is invoked before the first redemption settles; the shell and instance controls are not gated on Fleet.Out of Scope
Avoided Traps
Related
Parent: #14560. Successor to #16911 / PR #16912. Related: #16699. Adjacent future control-plane work: #16168. Draft implementation PR: #17548.
Origin Session ID: f47f948b-743b-4c11-84a8-fa60a567a148
Retrieval Hint:
query_raw_memories("Fleet cockpit shell instance switcher bearer handshake bounded heal")