Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24211,3 +24211,11 @@ P-SANDBOX.13 designed around this (the Add folder route never accepts a path fro
**Decision.** Recover resumes the recorded session. `FleetLaneManager.spawn` takes `resume: { sessionId, transcript, turns }`: the lane starts with that session id and turn count, its transcript seeded from the on-disk session (bounded like everything `#record` keeps), and the handshake runs the resume path (native `session/load` when advertised; otherwise `session/new` plus the transcript as the first prompt's recovery preamble, the same fallback an in-place respawn uses). The engine route builds the payload from `sessionMessages`; the ghost carries the latest run's session id. A session omp cannot load is a named refusal on the row, and the lane is not created.

**Consequences.** A recovered spoke continues where it stopped, on its own model, and promote lands in its conversation. Loading replays the history through the lane's notify handler, so observers attached before the spawn see the replayed chunks as tokens (the in-place respawn already behaves this way); `prompt()` clears the live buffers before the first real turn, so nothing replayed folds into it. The ghost row counts the ledger's assistant messages and the lane counts user turns, so the two numbers can differ after a recover.

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

**Context.** Issue #390. With a turn running, a click on another session in the sidebar (or New session) stopped it with no warning. The sidebar row called `resumeSession`, which posts `/api/session/load`; `AcpBackend.loadSession` (and `newSession`) run `clearTurnRecovery`, which sends `session/cancel` for the live turn. That cancel is correct (ADR-0385: it ended the "already running" wedge), but nothing asked the user first. Main is one omp process holding one session, so the only way to keep one session working while you work in another is to run one of them in a spoke. A second risk sits next to it: the sidebar lists every session in the workspace, including one a live spoke holds, and loading that into Main would put one session in two omp processes appending to the same file.

**Decision.** The engine enforces the rule and the renderer explains it. `AcpBackend.switchBlocker()` names what a switch would stop (a goal loop, an automation, or a turn), and `/api/session/load` and `/api/newSession` answer 409 `{busy, sessionId}` while it is set, unless the body carries `force: true`. `GET /api/session/busy` reports the same thing so the renderer can ask before anything moves. The sidebar click goes through `openSession`, planned by the pure `desktop/renderer/session_switch.ts`: a session held by a live spoke attaches to that spoke (or is refused with the attach reason, never loaded twice); Main's own session keeps today's adopt path; an idle Main switches as before; a busy Main docks a sheet above the composer (the spoke-ask slot, no timeout, Esc closes it) with Open as a spoke (default focus: `fleetSpawn` with the session id, which resumes it through the ADR-0401 path, then `promoteLane`), Stop it and switch (`force`), and Stay here. New session and the palette's New session use the same sheet, with Start it as a spoke. A refusal that arrives anyway (Main started working between the check and the load) re-adopts Main's turn and opens the sheet, so nothing is stopped.

**Consequences.** A click can no longer cancel work; stopping is always the labeled choice, and every client (desktop, PWA, a script) gets the same 409. Opening a session as a spoke uses the recorded model and the workspace folder, so the spoke shares Main's checkout: P-SWITCH.4 adds the folder-overlap warning and worktrees for new spokes. Swap when this turn ends, View only, the remembered choice, hub grouping (`hubSessionId`), and the engine-side single-owner registry are P-SWITCH.2 and .3. Switching the workspace folder still restarts the master child and is not guarded here.
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -1162,3 +1162,7 @@ demo-P-MODEL.4: ## P-MODEL.4 (ADR-0383): GPT-6 Sol and Luna. omp 18.2.7 -> 18.2.
.PHONY: demo-P-LEGIBLE.1
demo-P-LEGIBLE.1: ## P-LEGIBLE.1 (ADR-0384, issue #302): legible to Defender / Agent 365 without a content path. Each launch writes a metadata-only local-agent manifest (Defender's vendor / relatedProcess / autoApprove / mcpServers / localMcps vocabulary) to userData; MCP entries keep only name, type, URL origin or command basename, so no header, arg, env, path or query can leak. No hook seam, no listener, gate untouched.
$(BUN) test $(TEST_IGNORES) desktop/local_agent_manifest.test.ts harness/adr_numbering.test.ts

.PHONY: demo-P-SWITCH.1
demo-P-SWITCH.1: ## P-SWITCH.1 (ADR-0403, issue #390): opening a session never stops the running one unless you choose to. /api/session/load and /api/newSession answer 409 while Main is busy (turn, goal loop, automation) unless force; the sidebar and New session ask first and offer Open as a spoke (session resumed in a lane, composer attached) or Stop it and switch. A session a live spoke holds attaches to that spoke instead of loading a second copy. Proves the busy predicate on the real backend over the fake agent, the switch plan, and the sheet wording.
$(BUN) test $(TEST_IGNORES) desktop/renderer/session_switch.test.ts desktop/acp_backend_recovery.test.ts desktop/renderer/composer_target.test.ts harness/adr_numbering.test.ts
5 changes: 5 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5115,3 +5115,8 @@ Roadmap phases (each its own future increment + ADR for its frozen-contract delt
- **shipped:** one rule for auto-speak / conversation / digest (`desktop/voice_flags.ts`) shared by the engine store and the renderer; auto-speak off now clears conversation for good; every voice control writes through `applyVoicePatch`, which applies the click before the engine answers (speech, the one-clip player, cues and the conversation mic stop at once), drops answers to older writes, and repaints the chip, the popover and the Settings checkboxes from one state; the LUCID Agent stage no longer forces hands-free on for a user who set it and stays quiet with read-aloud off. Tests 7 (`voice_flags.test.ts`), renderer 1309 pass, tsc clean, served /app.js carries the fix. `make demo-P-VOICE.8`.
- **stubbed:** not clicked through in a live window here (neither the headless browser nor the host agent browser could open from the sandbox).
- **next:** in the rebuilt app: turn read-aloud on, start a long reply, uncheck it in the popover mid-sentence and confirm silence; open Settings > Voice and confirm the same boxes; restart in the LUCID Agent role and confirm hands-free stays off.

## P-SWITCH.1: opening a session never stops the running one unless you choose to (ADR-0403, issue #390)
- **shipped:** `/api/session/load` and `/api/newSession` answer 409 while Main is busy (`AcpBackend.switchBlocker`: goal loop, automation, or turn) unless `force`; `GET /api/session/busy`; the sidebar click (`openSession`), New session and the palette's New session plan through pure `session_switch.ts` and dock a sheet above the composer: Open as a spoke (resume via `fleetSpawn` with the session id, then promote), Stop it and switch, Stay here (Esc). A session a live spoke holds attaches to that spoke. Live check on an isolated engine with the fake agent: 409s kept the turn running, Esc left it running, Open as a spoke promoted "Refactor tests" while Main kept working, Stop it and switch cancelled the turn and loaded B. Full harness suite 5864 pass, 0 fail.
- **stubbed:** nothing in this increment; Swap when this turn ends, View only, the remembered choice, hub grouping and the engine-side single-owner registry are P-SWITCH.2 and .3; folder overlap and worktrees are P-SWITCH.4.
- **next:** P-SWITCH.2: engine single-owner registry for session ids (refuse a load or resume of a session a live lane holds) and live state badges on sidebar rows.
12 changes: 12 additions & 0 deletions desktop/acp_backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1451,6 +1451,18 @@ class Backend {
* Used by the delete route to close the session before removing its file (#53). */
currentSessionId(): string | null { return this.sessionId; }

/** P-SWITCH.1 (ADR-0403): why loading another session into Main, or starting a new one, would stop
* work right now, or null when nothing would be lost. loadSession/newSession run clearTurnRecovery,
* which cancels a live turn (ADR-0385, and correctly so), so the HTTP routes ask this first and refuse
* unless the caller explicitly chose to stop. A loop or automation outranks the turn it is running
* because it is the bigger thing the user would lose. */
switchBlocker(): string | null {
if (this.goalActive) return "a goal loop is running";
if (this.autoRunning) return "an automation is running";
if (this.askActive || this.recoveryTurn?.running) return "a turn is running";
return null;
}

/** P-INTERJECT.1: whether a chat turn is streaming right now (listener armed), and when it started.
* Feeds the /api/processes "Master chat turn" entry; a util completion riding the chat connection
* counts as busy too (it occupies the session either way). */
Expand Down
15 changes: 15 additions & 0 deletions desktop/acp_backend_recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,21 @@ describe("session-switch wedge: a cleared turn releases the session", () => {
}
});

// P-SWITCH.1 (ADR-0403): /api/session/load and /api/newSession refuse on this predicate. A blocker that
// missed a live turn lets a click cancel work again; one that outlived the turn locks switching for good.
describe("switch guard", () => {
test("switchBlocker names a live turn and clears once the turn ends", async () => {
process.env.FAKE_ACP_MODE = "hang";
expect(backend.switchBlocker()).toBeNull();
const turn = backend.prompt("still working", () => {});
await until(() => trace().some((t) => t.method === "session/prompt"));
expect(backend.switchBlocker()).toBe("a turn is running");
backend.cancel();
await turn;
expect(backend.switchBlocker()).toBeNull();
}, 30_000);
});

describe("master child revival", () => {
test("a dead master child is revived by the next prompt, resuming the same session", async () => {
process.env.FAKE_ACP_MODE = "crash";
Expand Down
23 changes: 21 additions & 2 deletions desktop/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1357,6 +1357,11 @@ const json = (data: unknown) =>
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
});

/** P-SWITCH.1 (ADR-0403): the one refusal both session routes send while Main is busy. 409 because the
* request is fine and the state is not; `busy` is the engine's reason, shown to the user verbatim. */
const switchRefused = (busy: string, sessionId: string | null) =>
Response.json({ ok: false, busy, sessionId, error: `Main is busy (${busy}). Send force: true to stop it and switch.` }, { status: 409, headers: { "cache-control": "no-store" } });

// P-SEC.1 (ADR-0209): a caught exception's message/stack must never flow into a client response
// (CWE-209/497 — CodeQL js/stack-trace-exposure). This control plane is loopback-only (ADR-0022 H1), so the
// real-world exposure is low, but we keep the boundary clean: log the FULL error server-side (dev console)
Expand Down Expand Up @@ -3673,7 +3678,16 @@ return Bun.serve({
syncStepTurns(sid, page.userTotal);
return json({ ok: true, data: { ...page, steps: readTurnSteps(sid) } });
}
if (p === "/api/session/load" && req.method === "POST") { const { id } = await readBody<{ id?: unknown }>(req); await backend.loadSession(String(id)); return json({ ok: true }); }
// P-SWITCH.1 (ADR-0403): opening a session never stops Main's work unless the caller chose to. Every
// client (desktop, PWA, a script) gets the same refusal; `force: true` is the explicit "stop it".
if (p === "/api/session/busy") return json({ ok: true, data: { busy: backend.switchBlocker(), sessionId: backend.currentSessionId() } });
if (p === "/api/session/load" && req.method === "POST") {
const { id, force } = await readBody<{ id?: unknown; force?: unknown }>(req);
const busy = force === true ? null : backend.switchBlocker();
if (busy) return switchRefused(busy, backend.currentSessionId());
await backend.loadSession(String(id));
return json({ ok: true });
}
if (p === "/api/session/delete" && req.method === "POST") {
const { id } = await readBody<{ id?: unknown }>(req);
const sid = String(id);
Expand Down Expand Up @@ -4520,7 +4534,12 @@ return Bun.serve({
return json({ ok: true, data: { recorded: true } });
}
// ADR-0009 Phase A: re-load the cross-session recall block for the fresh session (read-only).
if (p === "/api/newSession" && req.method === "POST") { await backend.newSession(); await refreshRecall(); return json({ ok: true }); }
if (p === "/api/newSession" && req.method === "POST") {
const { force } = await readBody<{ force?: unknown }>(req).catch(() => ({ force: undefined }));
const busy = force === true ? null : backend.switchBlocker(); // P-SWITCH.1 (ADR-0403)
if (busy) return switchRefused(busy, backend.currentSessionId());
await backend.newSession(); await refreshRecall(); return json({ ok: true });
}
// P-FLEET.L5 (ADR-0274): the reviewable timeline - every session on this machine (master chats,
// lane sessions labeled through the durable ledger, ingest throwaways), across ALL workspaces,
// newest first. Reading a point reuses the same transcript reader the sidebar resume uses; the
Expand Down
Loading
Loading