From 87621eb663ef98ba9457eb09798de462d6b1ce27 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 15 Oct 2026 08:44:14 -0400 Subject: [PATCH 1/3] fix(app): open a cloud channel's chat once its workspace start ends The compute service freezes the hosted channel for a whole start or stop, so services/channel refuses its socket (409, which the host reports as "Cannot reach "). The app selected the new channel and watched it immediately, then never watched again after the start; and the composer had read models once, failed, and kept that error with only Automatic, which is why Try again appeared not to recover: it re-watched the chat, but nothing re-read the models. The host now reports `computing` (starting or stopping) in the listing while compute-start or compute-stop runs. The conversation doesn't watch a computing channel and shows the transition instead of an error; when it ends, the chat is watched again. The composer reads models only while the chat is ready, so they load whenever the chat does, including after Try again. Fixes #420. Why: apps/host/src/gateway.ts#function lifecycle | the listing reports computing for the whole start or stop, because the service refuses the frozen channel until it ends; clients re-watch on its end Why: apps/app/src/conversation.tsx#const transcript | a computing channel is not watched; the transition ending is the signal to watch it again Why: apps/app/src/composer.tsx#function Composer | models are read only while the chat is ready, so a chat that recovers (start ends, Try again, move ends) re-reads them --- apps/app/src/composer.tsx | 6 ++++-- apps/app/src/conversation.tsx | 20 ++++++++++++-------- apps/host/src/gateway.ts | 34 ++++++++++++++++++++++++++++++++-- apps/host/src/protocol.ts | 2 ++ docs/architecture.md | 9 ++++++--- 5 files changed, 56 insertions(+), 15 deletions(-) diff --git a/apps/app/src/composer.tsx b/apps/app/src/composer.tsx index 062a5be5..fe632abb 100644 --- a/apps/app/src/composer.tsx +++ b/apps/app/src/composer.tsx @@ -134,7 +134,9 @@ export function Composer( // An offline channel can't answer; the conversation already says so, so don't blame providers. const offline = channel.state === "offline"; useEffect(() => { - if (status !== "open" || mode !== "ace" || offline) return; + // A chat that can't load can't answer either; asking again once it loads is how a refused + // channel's models recover, such as after a cloud workspace starts or Try again. + if (status !== "open" || mode !== "ace" || offline || !ready) return; let active = true; // Ask the channel itself: a hosted channel uses the service's credentials, not its workspace's. host.channel(channel.id, { op: "models" }).then( @@ -152,7 +154,7 @@ export function Composer( return () => { active = false; }; - }, [channel.id, mode, status, settingsVersion, offline]); + }, [channel.id, mode, status, settingsVersion, offline, ready]); const [{ model: picked, effort: pickedEffort }, changePick] = useLocalStorage( `ace:pick:${scope}/${id}`, UNPICKED, diff --git a/apps/app/src/conversation.tsx b/apps/app/src/conversation.tsx index e5931925..792329af 100644 --- a/apps/app/src/conversation.tsx +++ b/apps/app/src/conversation.tsx @@ -35,15 +35,19 @@ export function Conversation( const offline = channel.state === "offline"; const placement = channel.hosted || channel.host; const moving = channel.moving && `Moving to ${channel.moving}…`; + const computing = channel.computing + && `${channel.computing === "starting" ? "Starting" : "Stopping"} the cloud workspace…`; + const transition = moving || computing; + // The service refuses a computing channel, so the chat is watched again only once that ends. const transcript = useTranscript( - channel.id, + computing ? undefined : channel.id, id, offline, channel.moving ? `${placement} → ${channel.moving}` : placement, ); - // The watch retries once the host is back or the move ends, so its error until then is not the - // user's to retry. - const failed = !offline && !moving && transcript.error; + // The watch retries once the host is back or the move or compute transition ends, so its error + // until then is not the user's to retry. + const failed = !offline && !transition && transcript.error; const chat = transcript.info?.chats.find((value) => value.id === id); const model = chat?.model || channel.model; const people = useSyncExternalStore(host.subscribe, () => host.people); @@ -153,7 +157,7 @@ export function Conversation(

{offline ? `Offline: ${channel.host} is unreachable. This chat loads when it's back.` - : moving + : transition || failed || notice || (transcript.restarting @@ -167,9 +171,9 @@ export function Conversation( )}

- {transcript.live && (notice || moving || transcript.restarting) && ( + {transcript.live && (notice || transition || transcript.restarting) && (

- {notice || moving || "Channel is restarting…"} + {notice || transition || "Channel is restarting…"}

)} {transcript.live && ( @@ -184,7 +188,7 @@ export function Conversation( busy={transcript.busy} waiting={waiting} shared={transcript.shared} - ready={transcript.live && !moving && channel.state !== "archived"} + ready={transcript.live && !transition && channel.state !== "archived"} draft={draft} onDraftLoaded={onDraftLoaded} onSettings={onSettings} diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index e71763d4..b9a58e2a 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -84,6 +84,8 @@ const pending = new Set>(); const deleting = new Set(); /** Moves under way here; a fence an interrupted move left behind is not one. */ const moves = new Set(); +/** Cloud compute starts and stops under way here; each freezes its hosted channel until it ends. */ +const computing = new Map(); async function local(): Promise { const records = catalog.list(); @@ -125,6 +127,8 @@ async function local(): Promise { if (repo) listing.repo = repo; if (hosted) listing.hosted = hosted; if (record.compute) listing.compute = true; + const transition = computing.get(id); + if (transition) listing.computing = transition; if (record.moving && moves.has(id)) { listing.moving = record.moving.to === "local" ? name : new URL(record.moving.to).host; } @@ -315,6 +319,28 @@ async function remote(client: Client, host: string): Promise { return open; } +/** + * The service refuses the channel's connections while it starts or stops, so the listing says so + * beforehand and clients open the chat again once it ends. + */ +async function lifecycle( + channel: string, + transition: "starting" | "stopping", + run: () => Promise, +): Promise { + // A second request for the same channel waits behind the first at the service; only the first + // may end the listing's transition. + const first = !computing.has(channel); + if (first) computing.set(channel, transition); + await broadcast(); + try { + await run(); + } finally { + if (first) computing.delete(channel); + await broadcast(); + } +} + const owned = new Set(["archive", "archive-inactive", "delete"]); const windowOps = new Set(["window", "windows", "tab-rename", "tab-result"]); const moveOps = new Set(["move-targets", "move"]); @@ -514,9 +540,13 @@ async function handle( case "compute": return computeSettings(); case "compute-start": - return compute.start(request.channel, (waiting) => send({ id, waiting })); + return lifecycle( + request.channel, + "starting", + () => compute.start(request.channel, (waiting) => send({ id, waiting })), + ); case "compute-stop": - return compute.stop(request.channel); + return lifecycle(request.channel, "stopping", () => compute.stop(request.channel)); case "archive": if (request.archived) terminals.closeChannel(request.channel); await archive(request.channel, request.archived); diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index 5d970e6a..5989d8dd 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -92,6 +92,8 @@ export type Listing = { hosted?: string; /** The hosting service is the cloud compute beta's, whose container is the workspace. */ compute?: true; + /** A cloud compute start or stop under way here; the service refuses the channel until it ends. */ + computing?: "starting" | "stopping"; /** Where a move under way is taking the channel: a hosting service's host, or this host's name. */ moving?: string; /** Its peer host stopped answering while relaying a request or watch here; absent on older hosts. */ diff --git a/docs/architecture.md b/docs/architecture.md index fbaa7f5d..f147c34f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -453,9 +453,12 @@ can **Run in cloud** on the team's [compute service](../services/compute/README. `user.name` and `user.email` as the author; it then saves the record with `hosted` set to the service and `compute`. The app calls `start` through the host, which waits for `runner.connected` in `status`, and invokes the agent only after that. Starting again is the retry after a failure. -The channel's menu offers Stop compute (`stop`, which checkpoints first) and Start compute. Deleting -the channel calls the service's `DELETE /workspaces/:id`, which removes the container, checkpoint, -and hosted channel; if the service refuses, the host keeps the record so deleting can be retried. +The channel's menu offers Stop compute (`stop`, which checkpoints first) and Start compute. The +service freezes the hosted channel for a whole start or stop and refuses its connections, so the +host's listing reports `computing` while one runs; the app opens the chat and reads its models only +after it ends. Deleting the channel calls the service's `DELETE /workspaces/:id`, which removes +the container, checkpoint, and hosted channel; if the service refuses, the host keeps the record so +deleting can be retried. The service's settings live in `settings.json` beside the default model; its secret is the team's `ACE_SECRET`. From 4b3af603fe50bff46d3a3f1084e87e5249603e71 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 15 Oct 2026 08:44:26 -0400 Subject: [PATCH 2/3] chore: confirm lane notes for cloud channel transitions --- ...YQG7BVWEFZPD56A2BA0VE9-mode-ace-mode-channels-id-an.md | 6 +++--- ...ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md | 8 ++++++++ ...YZJ6CX726GBRDEEQ6MHGJ5-the-transcript-placement-inc.md | 5 +++-- ...ZSEZB8W901GJRWWAWPKJJQ-a-computing-channel-is-not-w.md | 8 ++++++++ ...4ZSF2X4SE8YJ11RWBGVRV77-a-refused-upgrade-such-as-a.md | 8 ++++++++ ...4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md | 6 +++--- ...Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md | 6 +++--- ...ZSEZATX8EYVT76QQVWFHYQ-the-listing-reports-computin.md | 8 ++++++++ 8 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 .lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md create mode 100644 .lane/memory/apps/app/src/conversation.tsx/01M4ZSEZB8W901GJRWWAWPKJJQ-a-computing-channel-is-not-w.md create mode 100644 .lane/memory/apps/host/src/client.ts/01M4ZSF2X4SE8YJ11RWBGVRV77-a-refused-upgrade-such-as-a.md create mode 100644 .lane/memory/apps/host/src/gateway.ts/01M4ZSEZATX8EYVT76QQVWFHYQ-the-listing-reports-computin.md diff --git a/.lane/memory/apps/app/src/composer.tsx/01M4YQG7BVWEFZPD56A2BA0VE9-mode-ace-mode-channels-id-an.md b/.lane/memory/apps/app/src/composer.tsx/01M4YQG7BVWEFZPD56A2BA0VE9-mode-ace-mode-channels-id-an.md index 2209381d..b4ad70fa 100644 --- a/.lane/memory/apps/app/src/composer.tsx/01M4YQG7BVWEFZPD56A2BA0VE9-mode-ace-mode-channels-id-an.md +++ b/.lane/memory/apps/app/src/composer.tsx/01M4YQG7BVWEFZPD56A2BA0VE9-mode-ace-mode-channels-id-an.md @@ -4,9 +4,9 @@ anchor: function Composer created: 2026-10-15T02:50:44Z norm: '1' sig: 6e0e6c5c8afbd01a -body_hash: 528621465b049324 -raw_hash: 2a7b64dabd487d6f -vouched: 2026-10-15T06:16:16Z +body_hash: 84d775a4379421b8 +raw_hash: 675641f4c82ebe48 +vouched: 2026-10-15T12:44:23Z lines: 99-282 --- diff --git a/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md b/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md new file mode 100644 index 00000000..f532f193 --- /dev/null +++ b/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md @@ -0,0 +1,8 @@ +--- +id: 01M4ZSEZBJJ3AMEBTR7SH36FW4 +anchor: function Composer +created: 2026-10-15T12:44:15Z +norm: '1' +--- + +models are read only while the chat is ready, so a chat that recovers (start ends, Try again, move ends) re-reads them diff --git a/.lane/memory/apps/app/src/conversation.tsx/01M4YZJ6CX726GBRDEEQ6MHGJ5-the-transcript-placement-inc.md b/.lane/memory/apps/app/src/conversation.tsx/01M4YZJ6CX726GBRDEEQ6MHGJ5-the-transcript-placement-inc.md index d5f92344..47becd69 100644 --- a/.lane/memory/apps/app/src/conversation.tsx/01M4YZJ6CX726GBRDEEQ6MHGJ5-the-transcript-placement-inc.md +++ b/.lane/memory/apps/app/src/conversation.tsx/01M4YZJ6CX726GBRDEEQ6MHGJ5-the-transcript-placement-inc.md @@ -4,8 +4,9 @@ anchor: const transcript created: 2026-10-15T05:11:37Z norm: '1' sig: 60c22dcb5a62f055 -body_hash: 89c8a40bfabe8a1c -raw_hash: f352c7936fac377d +body_hash: b4f8c93233925da6 +raw_hash: 65ded502d67083d8 +vouched: 2026-10-15T12:44:23Z lines: 40-45 --- diff --git a/.lane/memory/apps/app/src/conversation.tsx/01M4ZSEZB8W901GJRWWAWPKJJQ-a-computing-channel-is-not-w.md b/.lane/memory/apps/app/src/conversation.tsx/01M4ZSEZB8W901GJRWWAWPKJJQ-a-computing-channel-is-not-w.md new file mode 100644 index 00000000..0ffaf038 --- /dev/null +++ b/.lane/memory/apps/app/src/conversation.tsx/01M4ZSEZB8W901GJRWWAWPKJJQ-a-computing-channel-is-not-w.md @@ -0,0 +1,8 @@ +--- +id: 01M4ZSEZB8W901GJRWWAWPKJJQ +anchor: const transcript +created: 2026-10-15T12:44:15Z +norm: '1' +--- + +a computing channel is not watched; the transition ending is the signal to watch it again diff --git a/.lane/memory/apps/host/src/client.ts/01M4ZSF2X4SE8YJ11RWBGVRV77-a-refused-upgrade-such-as-a.md b/.lane/memory/apps/host/src/client.ts/01M4ZSF2X4SE8YJ11RWBGVRV77-a-refused-upgrade-such-as-a.md new file mode 100644 index 00000000..d5f58c3f --- /dev/null +++ b/.lane/memory/apps/host/src/client.ts/01M4ZSF2X4SE8YJ11RWBGVRV77-a-refused-upgrade-such-as-a.md @@ -0,0 +1,8 @@ +--- +id: 01M4ZSF2X4SE8YJ11RWBGVRV77 +anchor: function hostedSocket +created: 2026-10-15T12:44:18Z +norm: '1' +--- + +a refused upgrade, such as a frozen hosted channel's 409 during a compute start or stop, surfaces here as 'Cannot reach ' though the service is up diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md b/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md index be1abc09..c207e3aa 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-15T04:08:49Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 341760373f5a4c1f -raw_hash: 7486b0020a282b54 -vouched: 2026-10-15T12:20:01Z +body_hash: fb975c1e632066af +raw_hash: e91e7dcc66a7a4f9 +vouched: 2026-10-15T12:44:23Z lines: 266-519 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md b/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md index 887b5137..888b7c08 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-15T06:00:20Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 341760373f5a4c1f -raw_hash: 7486b0020a282b54 -vouched: 2026-10-15T12:20:01Z +body_hash: fb975c1e632066af +raw_hash: e91e7dcc66a7a4f9 +vouched: 2026-10-15T12:44:23Z lines: 281-561 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4ZSEZATX8EYVT76QQVWFHYQ-the-listing-reports-computin.md b/.lane/memory/apps/host/src/gateway.ts/01M4ZSEZATX8EYVT76QQVWFHYQ-the-listing-reports-computin.md new file mode 100644 index 00000000..62537a7e --- /dev/null +++ b/.lane/memory/apps/host/src/gateway.ts/01M4ZSEZATX8EYVT76QQVWFHYQ-the-listing-reports-computin.md @@ -0,0 +1,8 @@ +--- +id: 01M4ZSEZATX8EYVT76QQVWFHYQ +anchor: function lifecycle +created: 2026-10-15T12:44:15Z +norm: '1' +--- + +the listing reports computing for the whole start or stop, because the service refuses the frozen channel until it ends; clients re-watch on its end From 3d23ddad80863116c0f810e916cc6875bb224f02 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 15 Oct 2026 08:44:29 -0400 Subject: [PATCH 3/3] lane: sync cloud-open memory --- ...01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md | 4 ++++ ...01M4ZSEZB8W901GJRWWAWPKJJQ-a-computing-channel-is-not-w.md | 4 ++++ .../01M4ZSF2X4SE8YJ11RWBGVRV77-a-refused-upgrade-such-as-a.md | 4 ++++ ...01M4ZSEZATX8EYVT76QQVWFHYQ-the-listing-reports-computin.md | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md b/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md index f532f193..f3fa81a9 100644 --- a/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md +++ b/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md @@ -3,6 +3,10 @@ id: 01M4ZSEZBJJ3AMEBTR7SH36FW4 anchor: function Composer created: 2026-10-15T12:44:15Z norm: '1' +sig: 6e0e6c5c8afbd01a +body_hash: 84d775a4379421b8 +raw_hash: 675641f4c82ebe48 +lines: 101-290 --- models are read only while the chat is ready, so a chat that recovers (start ends, Try again, move ends) re-reads them diff --git a/.lane/memory/apps/app/src/conversation.tsx/01M4ZSEZB8W901GJRWWAWPKJJQ-a-computing-channel-is-not-w.md b/.lane/memory/apps/app/src/conversation.tsx/01M4ZSEZB8W901GJRWWAWPKJJQ-a-computing-channel-is-not-w.md index 0ffaf038..6112786f 100644 --- a/.lane/memory/apps/app/src/conversation.tsx/01M4ZSEZB8W901GJRWWAWPKJJQ-a-computing-channel-is-not-w.md +++ b/.lane/memory/apps/app/src/conversation.tsx/01M4ZSEZB8W901GJRWWAWPKJJQ-a-computing-channel-is-not-w.md @@ -3,6 +3,10 @@ id: 01M4ZSEZB8W901GJRWWAWPKJJQ anchor: const transcript created: 2026-10-15T12:44:15Z norm: '1' +sig: 60c22dcb5a62f055 +body_hash: b4f8c93233925da6 +raw_hash: 65ded502d67083d8 +lines: 42-47 --- a computing channel is not watched; the transition ending is the signal to watch it again diff --git a/.lane/memory/apps/host/src/client.ts/01M4ZSF2X4SE8YJ11RWBGVRV77-a-refused-upgrade-such-as-a.md b/.lane/memory/apps/host/src/client.ts/01M4ZSF2X4SE8YJ11RWBGVRV77-a-refused-upgrade-such-as-a.md index d5f58c3f..fab5abb6 100644 --- a/.lane/memory/apps/host/src/client.ts/01M4ZSF2X4SE8YJ11RWBGVRV77-a-refused-upgrade-such-as-a.md +++ b/.lane/memory/apps/host/src/client.ts/01M4ZSF2X4SE8YJ11RWBGVRV77-a-refused-upgrade-such-as-a.md @@ -3,6 +3,10 @@ id: 01M4ZSF2X4SE8YJ11RWBGVRV77 anchor: function hostedSocket created: 2026-10-15T12:44:18Z norm: '1' +sig: c9aa713958e3748d +body_hash: 828dccd00ce65d6a +raw_hash: 588129bc10efcbae +lines: 70-82 --- a refused upgrade, such as a frozen hosted channel's 409 during a compute start or stop, surfaces here as 'Cannot reach ' though the service is up diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4ZSEZATX8EYVT76QQVWFHYQ-the-listing-reports-computin.md b/.lane/memory/apps/host/src/gateway.ts/01M4ZSEZATX8EYVT76QQVWFHYQ-the-listing-reports-computin.md index 62537a7e..03668ac5 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4ZSEZATX8EYVT76QQVWFHYQ-the-listing-reports-computin.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4ZSEZATX8EYVT76QQVWFHYQ-the-listing-reports-computin.md @@ -3,6 +3,10 @@ id: 01M4ZSEZATX8EYVT76QQVWFHYQ anchor: function lifecycle created: 2026-10-15T12:44:15Z norm: '1' +sig: b4b56897d458a11c +body_hash: 6c6cffd05b064741 +raw_hash: 31b7510f85e691d2 +lines: 326-342 --- the listing reports computing for the whole start or stop, because the service refuses the frozen channel until it ends; clients re-watch on its end