Context
Sub of Epic #13015 (Fleet Manager MVP — define, start, observe). PR #14960 (@neo-opus-vega) landed define + the honest lifecycle round-trip: a card's control fires an intent-only lifecycleIntent, FleetCockpitController.onAgentLifecycleIntent drives the C2 adapter (fleetLifecycleIntentAdapter.handleFleetLifecycleIntent) → the registry bridge → honest pendingAction / controlReason state on the record. That is start working through the UI.
What is missing is observe: after a start succeeds, the card never learns the agent is running.
Reshaped from @neo-opus-vega's /peer-role correction on the define→start→observe seam (my original premise — "the pane has no start control" — was wrong: the control exists in the FleetCockpit AgentCard + the Control panel; only the post-settle observe half is the real gap). Session dialogue on #13015; his handoff: "post-intent roster/runtime refresh + the 'starting' state rendered distinctly … completes define→start→observe honestly."
The Problem
Two observable dishonesties in the shipped cockpit, both verified in code on dev:
Stale off after a successful start. FleetCockpit.loadRoster() is the only thing that maps live runtime state onto the roster records (bridge.fleetRoster() → record.set(row) per agentId), and it is called exactly once, at onConstructed (apps/agentos/view/fleet/FleetCockpit.mjs:156). FleetCockpitController.onAgentLifecycleIntent calls handleFleetLifecycleIntent(...) fire-and-forget (FleetCockpitController.mjs:69) and never re-polls. So the sequence is: click start → pendingAction:'start' → adapter settles (pendingAction:null, success) → the card's state is still the pre-start off. The operator starts an agent, the spinner clears, and the card says it is off. Only a page reload reveals the truth. For the §04 PoC bar ("@tobiu starts one agent from the UI") that is the whole point failing silently.
starting collapses into off on the primary glyph. AgentCard.applyRecord renders the state-dot from displayState (running→ok, else→off) and does not consult pendingAction (AgentCard.mjs:239-242). While a start is in-flight the control-status text does show start… (:269), but the state-dot — the at-a-glance status — reads off. The primary glyph disagrees with the secondary text, and there is no distinct starting state.
The Architectural Reality
apps/agentos/view/fleet/FleetCockpitController.mjs — the B4÷C2 composition root; owns the wire (cards stay intent-only). onAgentLifecycleIntent (:66) and onStartFleet (:37) call the adapter without consuming its returned {ok, status} result.
apps/agentos/view/fleet/fleetLifecycleIntentAdapter.mjs — already returns {accepted, ok, status:'settled'|'rejected'|'timeout'|'unauthorized', ...} (:184). The refresh trigger is a matter of consuming a value the adapter already yields.
apps/agentos/view/fleet/FleetCockpit.mjs — loadRoster() (:247) is idempotent + fail-closed by construction (reconciles the Store, keeps last-known on a malformed/absent answer); safe to call repeatedly. It is the existing refresh mechanism, invoked at the wrong (single) point.
apps/agentos/view/fleet/AgentCard.mjs — applyRecord() (:215) is the one read-side consumer of the record; the state-dot set() is :239.
The fix consumes existing surfaces (the adapter's return value; the record's existing pendingAction); it does not cross into transport and adds no bridge path.
The Fix
FleetCockpitController — consume the adapter result: after handleFleetLifecycleIntent(...) resolves with a non-rejected settle, trigger this.component.loadRoster() so runtime truth re-materializes onto the record. onStartFleet refreshes once after the fan-out settles (not per-card). Fail-closed: a rejected/timeout intent already renders its honest reason; no refresh is forced on it.
AgentCard.applyRecord — when pendingAction is set, render the state-dot as a distinct transitional state (starting for a pending start, stopping for a pending stop) instead of the stale displayState, so the primary glyph agrees with the … text and never shows off mid-start.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
FleetCockpitController.onAgentLifecycleIntent / onStartFleet |
this leaf (behavior add) |
await the adapter result; on non-rejected settle call loadRoster() (once for fan-out) |
rejected/timeout → no refresh, honest reason stands |
method JSDoc |
controller spec: settle→refresh, reject→no-refresh |
AgentCard state-dot vocabulary |
ADR 0032 honest-states render |
pendingAction → distinct starting/stopping dot state |
no pending → existing displayState unchanged |
applyRecord JSDoc |
card spec asserts pending→starting, absent→prior state |
StateDot state values (verify at build) |
existing dot vocabulary |
add starting/stopping visual if absent |
reuse an existing transitional state if one exists |
JSDoc |
dot renders the new state distinctly |
Decision Record impact
aligned-with ADR 0032 (the #14445 cockpit render-model / honest-states rules — this makes the state-dot honest during a transition). No amendment; no new ADR needed. Not AiConfig (ADR-0019 gate N/A).
Acceptance Criteria
Out of Scope
- The real spawn behind the bridge (Euclid's #14951) — this leaf wires against the stable
FLEET_WIRE_METHODS bridge surface; the end-to-end "a real process boots" proof co-gates there.
- A runnable Brain under the packaged shell (@neo-opus-vega's #14967) — this leaf works in dev-server mode per #13015's parallelism clause.
- Polling/interval-based liveness (a continuous heartbeat) — this is event-driven refresh on a settled intent only; a periodic runtime poll, if wanted, is a separate leaf.
- Any change to
Accounts.mjs (the config keeper view) or the C2 adapter's transport contract.
Avoided Traps
- Optimistic success — rejected: the refresh fires only on a genuine settle; a rejected intent keeps its honest reason. The card never fakes running.
- Refresh-per-card on fan-out — rejected: N cards → N
fleetRoster() polls is wasteful and racey; one refresh after the batch.
- A second start control (my original wrong premise) — rejected: the control exists; only observe was missing.
- Continuous polling — rejected as scope creep: event-driven post-settle refresh is the honest minimum for the PoC; a liveness heartbeat is a distinct decision.
Related
Epic #13015 · consumes #14960 (define + lifecycle round-trip, merged) · co-gates real-spawn with #14951 · disjoint from #14967 (Brain under shell) · governed by ADR 0032 render-model.
Live latest-open sweep: checked latest 20 open issues + FM-scope search at 2026-07-10T17:2xZ; no equivalent found (#14964 is config, #14951 is spawn, #14967 is hosting, #14972 is launch-coverage — all distinct).
Origin Session ID: d6f084eb-c9bc-4551-8e2e-2bfed6a927aa
Retrieval Hint: "fleet cockpit loadRoster re-poll after lifecycle intent settle starting state observe verb"
Context
Sub of Epic #13015 (Fleet Manager MVP — define, start, observe). PR #14960 (@neo-opus-vega) landed define + the honest lifecycle round-trip: a card's control fires an intent-only
lifecycleIntent,FleetCockpitController.onAgentLifecycleIntentdrives the C2 adapter (fleetLifecycleIntentAdapter.handleFleetLifecycleIntent) → the registry bridge → honestpendingAction/controlReasonstate on the record. That is start working through the UI.What is missing is observe: after a start succeeds, the card never learns the agent is running.
Reshaped from @neo-opus-vega's
/peer-rolecorrection on the define→start→observe seam (my original premise — "the pane has no start control" — was wrong: the control exists in the FleetCockpit AgentCard + the Control panel; only the post-settle observe half is the real gap). Session dialogue on #13015; his handoff: "post-intent roster/runtime refresh + the 'starting' state rendered distinctly … completes define→start→observe honestly."The Problem
Two observable dishonesties in the shipped cockpit, both verified in code on
dev:Stale
offafter a successful start.FleetCockpit.loadRoster()is the only thing that maps live runtime state onto the roster records (bridge.fleetRoster()→record.set(row)per agentId), and it is called exactly once, atonConstructed(apps/agentos/view/fleet/FleetCockpit.mjs:156).FleetCockpitController.onAgentLifecycleIntentcallshandleFleetLifecycleIntent(...)fire-and-forget (FleetCockpitController.mjs:69) and never re-polls. So the sequence is: click start →pendingAction:'start'→ adapter settles (pendingAction:null, success) → the card'sstateis still the pre-startoff. The operator starts an agent, the spinner clears, and the card says it is off. Only a page reload reveals the truth. For the §04 PoC bar ("@tobiu starts one agent from the UI") that is the whole point failing silently.startingcollapses intooffon the primary glyph.AgentCard.applyRecordrenders the state-dot fromdisplayState(running→ok, else→off) and does not consultpendingAction(AgentCard.mjs:239-242). While a start is in-flight the control-status text does showstart…(:269), but the state-dot — the at-a-glance status — readsoff. The primary glyph disagrees with the secondary text, and there is no distinctstartingstate.The Architectural Reality
apps/agentos/view/fleet/FleetCockpitController.mjs— the B4÷C2 composition root; owns the wire (cards stay intent-only).onAgentLifecycleIntent(:66) andonStartFleet(:37) call the adapter without consuming its returned{ok, status}result.apps/agentos/view/fleet/fleetLifecycleIntentAdapter.mjs— already returns{accepted, ok, status:'settled'|'rejected'|'timeout'|'unauthorized', ...}(:184). The refresh trigger is a matter of consuming a value the adapter already yields.apps/agentos/view/fleet/FleetCockpit.mjs—loadRoster()(:247) is idempotent + fail-closed by construction (reconciles the Store, keeps last-known on a malformed/absent answer); safe to call repeatedly. It is the existing refresh mechanism, invoked at the wrong (single) point.apps/agentos/view/fleet/AgentCard.mjs—applyRecord()(:215) is the one read-side consumer of the record; the state-dotset()is:239.The fix consumes existing surfaces (the adapter's return value; the record's existing
pendingAction); it does not cross into transport and adds no bridge path.The Fix
FleetCockpitController— consume the adapter result: afterhandleFleetLifecycleIntent(...)resolves with a non-rejected settle, triggerthis.component.loadRoster()so runtime truth re-materializes onto the record.onStartFleetrefreshes once after the fan-out settles (not per-card). Fail-closed: a rejected/timeout intent already renders its honest reason; no refresh is forced on it.AgentCard.applyRecord— whenpendingActionis set, render the state-dot as a distinct transitional state (startingfor a pendingstart,stoppingfor a pendingstop) instead of the staledisplayState, so the primary glyph agrees with the…text and never showsoffmid-start.Contract Ledger Matrix
FleetCockpitController.onAgentLifecycleIntent/onStartFleetloadRoster()(once for fan-out)AgentCardstate-dot vocabularypendingAction→ distinctstarting/stoppingdot statedisplayStateunchangedapplyRecordJSDocStateDotstate values (verify at build)starting/stoppingvisual if absentDecision Record impact
aligned-with ADR 0032(the #14445 cockpit render-model / honest-states rules — this makes the state-dot honest during a transition). No amendment; no new ADR needed. Not AiConfig (ADR-0019 gate N/A).Acceptance Criteria
startintent settles successfully, the cockpit re-polls the roster so the card's runtime state advances fromoffwithout a page reload.onStartFleetfan-out triggers exactly one roster refresh after the batch settles, not one per card.pendingActionis set, the state-dot renders a distinctstarting/stoppingstate (never the staleoff).FleetCockpitLifecycleNLe2e (extended to assert post-settle roster state) + focused unit specs for the controller refresh-trigger and the card vocabulary.Out of Scope
FLEET_WIRE_METHODSbridge surface; the end-to-end "a real process boots" proof co-gates there.Accounts.mjs(the config keeper view) or the C2 adapter's transport contract.Avoided Traps
fleetRoster()polls is wasteful and racey; one refresh after the batch.Related
Epic #13015 · consumes #14960 (define + lifecycle round-trip, merged) · co-gates real-spawn with #14951 · disjoint from #14967 (Brain under shell) · governed by ADR 0032 render-model.
Live latest-open sweep: checked latest 20 open issues + FM-scope search at 2026-07-10T17:2xZ; no equivalent found (#14964 is config, #14951 is spawn, #14967 is hosting, #14972 is launch-coverage — all distinct).
Origin Session ID: d6f084eb-c9bc-4551-8e2e-2bfed6a927aa
Retrieval Hint: "fleet cockpit loadRoster re-poll after lifecycle intent settle starting state observe verb"