LearnNewsExamplesServices
Frontmatter
id17547
titleCockpit exhausts bearer redemption before Fleet is ready
stateClosed
labels
bugdeveloper-experienceaitestingregressionagent-os
assigneesneo-gpt-emmy
createdAtAug 22, 2026, 4:09 PM
updatedAtAug 22, 2026, 6:25 PM
githubUrlhttps://github.com/neomjs/neo/issues/17547
authorneo-gpt-emmy
commentsCount1
parentIssue14560
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 22, 2026, 6:25 PM

Cockpit exhausts bearer redemption before Fleet is ready

Closed Backlog/active-chunk-18 bugdeveloper-experienceaitestingregressionagent-os
neo-gpt-emmy
neo-gpt-emmy commented on Aug 22, 2026, 4:09 PM

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:

  1. apps/agentos/app.mjs performs one top-level awaited handshake request before onStart().
  2. A refusal settles immediately; onStart() performs one more request back-to-back.
  3. If both finish before Fleet binds, no later event retries credential acquisition.
  4. 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

  1. 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.
  2. 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.
  3. 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.
  4. When redemption succeeds while the expected bridge still owns the slot, pass the pair into establishFleetSessionCustody(); its authenticated proof promotes the candidate.
  5. Strengthen detached-candidate promotion with a published-bridge CAS so an operator switch during verification cannot be overwritten after the proof returns.
  6. 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

  • A delayed handshake proves Neo.app() is invoked before the first redemption settles; the shell and instance controls are not gated on Fleet.
  • A handshake that becomes available inside the bounded heal window establishes and verifies the bridge without Neural Link/manual injection.
  • Exhaustion, refusal, malformed response, and timeout leave the shell rendered with a fail-closed bridge and no surviving retry timer.
  • Changing or manually re-wiring the published bridge cancels the old heal before establishment.
  • Changing the published bridge while a detached candidate is verifying prevents that candidate from promoting or retiring ingress.
  • Existing bearer custody, no-downgrade, same-profile verified promotion, switcher, and FleetCockpit liveness suites remain green.
  • A live one-command receipt shows: shell first; disconnected state remains operable; delayed Fleet arrival heals the bridge; affected panes advance through their existing state paths.

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")