Skip to content

P-SWITCH.1: opening a session never stops the running one unless you choose to (ADR-0403) - #392

Merged
mlcyclops merged 3 commits into
masterfrom
feat/p-switch-1
Sep 26, 2026
Merged

mlcyclops merged 3 commits into
masterfrom
feat/p-switch-1

Conversation

@emertins

Copy link
Copy Markdown
Collaborator

First increment of #390. Stacked on #389 (P-FLEET.L17): Open as a spoke resumes the session through the sessionId spawn path that #389 adds, so this PR targets that branch. Merge #389 first; GitHub then retargets this one to master.

What changes

  • Engine guard. AcpBackend.switchBlocker() says what a switch would stop: a goal loop, an automation, or a turn. While it is set, /api/session/load and /api/newSession answer 409 {busy, sessionId} unless the body carries force: true. GET /api/session/busy reports the same thing. Every client (desktop, PWA, a script) gets the same refusal. The ADR-0385 cancel inside clearTurnRecovery is unchanged; it now only runs when the user chose to stop.
  • Renderer. The sidebar click goes through openSession, planned by the pure desktop/renderer/session_switch.ts:
    • A session a live spoke holds attaches to that spoke, or is refused with the attach reason. It is never loaded a second time.
    • Main's own session keeps today's adopt path.
    • With Main idle, the switch works as before.
    • With Main busy, a sheet docks above the composer, in the spoke-ask slot. It has no timeout, and focus starts on the safe choice. Its options:
      • Open as a spoke: fleetSpawn with the session id, then promoteLane. Main keeps running.
      • Stop it and switch: the load with force.
      • Stay here: also Esc.
  • New session (button and palette) uses the same sheet, offering Start it as a spoke and Stop it and start new.
  • Race path. If the engine refuses after the check (Main started working in between), the renderer re-adopts Main's turn and opens the sheet. Nothing is stopped.

Proof

  • make demo-P-SWITCH.1: 60 pass. It covers the busy predicate on the real backend over the fake ACP agent, the switch plan, and the sheet wording.
  • Full harness suite: 5864 pass, 0 fail. Root, desktop and server tsc are clean.
  • Renderer rebuilt. Served /app.js contains switchSheet and /api/session/busy and no longer contains the old void resumeSession(s.dataset.sid). Served /styles.css contains #switchSheet.
  • Live, on an isolated engine (temp HOME, fake omp in hang mode, two fixture sessions), driven through headless Edge:
    1. With Main idle, clicking A loaded it and no sheet appeared.
    2. With a turn running, POST /api/session/load and /api/newSession without force returned 409 ("a turn is running"), and the turn kept running.
    3. Clicking B showed the sheet ("Fix login flow" is still working / Opening "Refactor tests" here would stop it), with focus on Open as a spoke. Esc closed it and the turn kept running.
    4. New session showed the sheet with Start it as a spoke / Stop it and start new / Stay here.
    5. Open as a spoke put the "Refactor tests" lane in awaiting-input, promoted, with the ON SPOKE banner showing. Main's turn was still running.
    6. Clicking Main's own session showed no sheet. Stop it and switch ended the turn, and Main now holds B.

Not in this increment (tracked in #390)

  • Swap when this turn ends, View only, and a remembered choice.
  • hubSessionId and orbit grouping by hub.
  • The engine-side single-owner registry and sidebar live badges (P-SWITCH.2).
  • Folder overlap and worktrees for new spokes (P-SWITCH.4).
  • Switching the workspace folder still restarts the master child; this PR does not guard it.

…choose to (ADR-0402, #390)

/api/session/load and /api/newSession answer 409 while Main is busy (goal
loop, automation, or turn) unless force. The sidebar, New session and the
palette ask first and dock a sheet above the composer: Open as a spoke
(resume the session in a lane and attach), Stop it and switch, Stay here.
A session a live spoke holds attaches to that spoke instead of loading a
second copy.
@emertins emertins changed the title P-SWITCH.1: opening a session never stops the running one unless you choose to (ADR-0402) P-SWITCH.1: opening a session never stops the running one unless you choose to (ADR-0403) Sep 26, 2026
@emertins

Copy link
Copy Markdown
Collaborator Author

Renumbered to ADR-0403: #391 (P-FLEET.L20) already uses ADR-0402. Demo re-run: 60 pass.

@mlcyclops mlcyclops left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Integration is clean on top of #389 and #391 (tsc clean in root, desktop and server; the full bun suite passes 5881 / 0 fail; make demo-P-SWITCH.1 passes 60 / 60). I also drove it live on an isolated engine with a fake omp in hang mode:

  • With Main idle, clicking another session loaded it and no sheet appeared.
  • With a turn running, POST /api/session/load and POST /api/newSession without force returned 409 ("a turn is running"), and the turn kept running.
  • Clicking a second session opened the sheet with focus on Open as a spoke. Esc closed it and the turn kept running.
  • Open as a spoke put a promoted lane in awaiting-input with the ON SPOKE banner showing, and Main's turn was still running.

On master, every sidebar click silently cancels the turn, so this is a strict improvement. Review turned up edge cases that are not regressions against master but belong in the #390 follow-ups:

  1. Forced switch during a goal loop does not stop the loop. When switchBlocker() says "a goal loop is running", force: true goes straight to loadSession/newSession, and nothing cancels the goal (goalCancelled/cancelGoal). runGoal can then continue its checker or next iteration in the newly loaded session. The sheet's "Stop it and switch" promises a stop that does not happen. Cancel the goal (and any Main-owned automation) before the forced load.
  2. agent-kind automations block the switch. autoRunning also covers them, but they run through startAgentRun(), not Main's ACP session, so switching would not stop them. Scope the blocker to automations that own Main.
  3. The race path can leave the wrong view. On a 409 caused by a goal or automation between turns, recoverMasterTurn() has nothing to adopt, so the renderer keeps showing the target session while the engine still holds the old one. Reload the 409's sessionId transcript in that case.
  4. Ownership fails open. fleetStatus() returning null becomes "no lanes", and stopped/error lanes count as released even though stop() is not awaited and error does not stop the client. Either can plan a load of a session a lane process may still hold. This is covered once the engine-side owner registry (P-SWITCH.2) lands.
  5. Stale plans are not discarded. openSession awaits two preflights before any epoch check, so clicking A then B quickly can let A's late plan win. Take a generation number before the await.

@mlcyclops
mlcyclops merged commit 073755b into master Sep 26, 2026
8 of 9 checks passed
@mlcyclops
mlcyclops deleted the feat/p-switch-1 branch September 26, 2026 19:38
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.

3 participants