diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5APK9YCQH467YNMKAC8BT5B-a-move-to-a-cloud-workspace.md b/.lane/attic/apps/host/src/gateway.ts/01M5APK9YCQH467YNMKAC8BT5B-a-move-to-a-cloud-workspace.md similarity index 100% rename from .lane/memory/apps/host/src/gateway.ts/01M5APK9YCQH467YNMKAC8BT5B-a-move-to-a-cloud-workspace.md rename to .lane/attic/apps/host/src/gateway.ts/01M5APK9YCQH467YNMKAC8BT5B-a-move-to-a-cloud-workspace.md diff --git a/.lane/memory/apps/app/src/settings.tsx/01M5AYX7F0E4JW72ECJTK6SNZK-every-provider-link-opens-se.md b/.lane/memory/apps/app/src/settings.tsx/01M5AYX7F0E4JW72ECJTK6SNZK-every-provider-link-opens-se.md index 93986cf1..e71d4787 100644 --- a/.lane/memory/apps/app/src/settings.tsx/01M5AYX7F0E4JW72ECJTK6SNZK-every-provider-link-opens-se.md +++ b/.lane/memory/apps/app/src/settings.tsx/01M5AYX7F0E4JW72ECJTK6SNZK-every-provider-link-opens-se.md @@ -6,7 +6,7 @@ norm: '1' sig: 8d6f8142ef1a23f2 body_hash: ab1332a896bdd893 raw_hash: b048e59ec08c6130 -vouched: 2026-10-19T20:57:54Z +vouched: 2026-10-19T21:09:57Z lines: 325-537 --- diff --git a/.lane/memory/apps/app/src/settings.tsx/01M5AZ0WJH0JC0ANYXJZEBSZFF-section-buttons-focus-themse.md b/.lane/memory/apps/app/src/settings.tsx/01M5AZ0WJH0JC0ANYXJZEBSZFF-section-buttons-focus-themse.md index 9e0a0458..24a2e498 100644 --- a/.lane/memory/apps/app/src/settings.tsx/01M5AZ0WJH0JC0ANYXJZEBSZFF-section-buttons-focus-themse.md +++ b/.lane/memory/apps/app/src/settings.tsx/01M5AZ0WJH0JC0ANYXJZEBSZFF-section-buttons-focus-themse.md @@ -6,7 +6,7 @@ norm: '1' sig: 8d6f8142ef1a23f2 body_hash: ab1332a896bdd893 raw_hash: b048e59ec08c6130 -vouched: 2026-10-19T20:57:54Z +vouched: 2026-10-19T21:09:57Z lines: 325-545 --- diff --git a/.lane/memory/apps/host/src/compute.ts/01M5AHG0YFSH0H05S3Y0N16BKF-git-worktree-list-prints-rea.md b/.lane/memory/apps/host/src/compute.ts/01M5AHG0YFSH0H05S3Y0N16BKF-git-worktree-list-prints-rea.md index 0328e5cc..c0fae969 100644 --- a/.lane/memory/apps/host/src/compute.ts/01M5AHG0YFSH0H05S3Y0N16BKF-git-worktree-list-prints-rea.md +++ b/.lane/memory/apps/host/src/compute.ts/01M5AHG0YFSH0H05S3Y0N16BKF-git-worktree-list-prints-rea.md @@ -3,10 +3,10 @@ id: 01M5AHG0YFSH0H05S3Y0N16BKF anchor: function lanes created: 2026-10-19T16:56:39Z norm: '1' -sig: a117f6b009a0a5ab -body_hash: 608b9efe149e4af0 -raw_hash: eaf0bb12be123591 -vouched: 2026-10-19T18:03:00Z +sig: 258bf555fdfa6445 +body_hash: 163dbfda499c9a1e +raw_hash: 545a08e7b1625938 +vouched: 2026-10-19T21:09:57Z lines: 67-112 --- diff --git a/.lane/memory/apps/host/src/compute.ts/01M5AN9A1YPT5XAP3TS3ACX5BT-lane-clones-ignored-files-li.md b/.lane/memory/apps/host/src/compute.ts/01M5AN9A1YPT5XAP3TS3ACX5BT-lane-clones-ignored-files-li.md index c5012652..fc370426 100644 --- a/.lane/memory/apps/host/src/compute.ts/01M5AN9A1YPT5XAP3TS3ACX5BT-lane-clones-ignored-files-li.md +++ b/.lane/memory/apps/host/src/compute.ts/01M5AN9A1YPT5XAP3TS3ACX5BT-lane-clones-ignored-files-li.md @@ -3,9 +3,10 @@ id: 01M5AN9A1YPT5XAP3TS3ACX5BT anchor: function lanes created: 2026-10-19T18:02:53Z norm: '1' -sig: a117f6b009a0a5ab -body_hash: 608b9efe149e4af0 -raw_hash: eaf0bb12be123591 +sig: 258bf555fdfa6445 +body_hash: 163dbfda499c9a1e +raw_hash: 545a08e7b1625938 +vouched: 2026-10-19T21:09:57Z lines: 67-120 --- 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 7a8a863f..060edca9 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: 0dbbb4a75790fa93 -raw_hash: 9d6d9723ef47a7dd -vouched: 2026-10-19T21:09:44Z +body_hash: 28e153c79ae61537 +raw_hash: 640ff85ff07439ad +vouched: 2026-10-19T21:15:06Z 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 7364626c..deafa3d7 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: 0dbbb4a75790fa93 -raw_hash: 9d6d9723ef47a7dd -vouched: 2026-10-19T21:09:44Z +body_hash: 28e153c79ae61537 +raw_hash: 640ff85ff07439ad +vouched: 2026-10-19T21:15:07Z lines: 281-561 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md b/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md new file mode 100644 index 00000000..1426f5a3 --- /dev/null +++ b/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md @@ -0,0 +1,14 @@ +--- +id: 01M5AZZV2CRMNFXZKM0B3AWRVX +anchor: function handle +created: 2026-10-19T21:09:57Z +norm: '1' +sig: e597b5fbc8e664ce +body_hash: 28e153c79ae61537 +raw_hash: 640ff85ff07439ad +vouched: 2026-10-19T21:15:07Z +lines: 384-700 +supersedes: 01M5APK9YCQH467YNMKAC8BT5B +--- + +a move to a cloud workspace ends by starting it, so the listing reports computing from the move's fence through the start (move's starting wrapper); the agent's lane push runs before that so its chat stays open diff --git a/.lane/memory/apps/host/src/move.ts/01M4YW0PE5A5HQNJTBG3961AF3-nothing-that-can-wait-on-a-p.md b/.lane/memory/apps/host/src/move.ts/01M4YW0PE5A5HQNJTBG3961AF3-nothing-that-can-wait-on-a-p.md index 4ccd461d..389040d8 100644 --- a/.lane/memory/apps/host/src/move.ts/01M4YW0PE5A5HQNJTBG3961AF3-nothing-that-can-wait-on-a-p.md +++ b/.lane/memory/apps/host/src/move.ts/01M4YW0PE5A5HQNJTBG3961AF3-nothing-that-can-wait-on-a-p.md @@ -4,9 +4,9 @@ anchor: function move created: 2026-10-15T04:09:38Z norm: '1' sig: 8cd687d651283c6b -body_hash: 1bd46335713b89d5 -raw_hash: 7a9e8ac3b20028f5 -vouched: 2026-10-19T16:56:54Z +body_hash: 2ff747cd385d660c +raw_hash: 3689b92e77a90616 +vouched: 2026-10-19T21:09:57Z lines: 257-271 --- diff --git a/.lane/memory/apps/host/src/move.ts/01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md b/.lane/memory/apps/host/src/move.ts/01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md index 5bf1362e..e211c5ee 100644 --- a/.lane/memory/apps/host/src/move.ts/01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md +++ b/.lane/memory/apps/host/src/move.ts/01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md @@ -4,8 +4,9 @@ anchor: function toCompute created: 2026-10-19T20:56:11Z norm: '1' sig: 21af9e438a769213 -body_hash: 8574c71308234fbf -raw_hash: beeaf7b2694ba561 +body_hash: 06f3a62fc503ff0b +raw_hash: ee652da2e9d57120 +vouched: 2026-10-19T21:09:57Z lines: 260-326 --- diff --git a/.lane/memory/apps/host/src/move.ts/01M5AZZDBG1F569D8F4BWRV4PS-the-agent-s-push-runs-before.md b/.lane/memory/apps/host/src/move.ts/01M5AZZDBG1F569D8F4BWRV4PS-the-agent-s-push-runs-before.md new file mode 100644 index 00000000..eda7cb4e --- /dev/null +++ b/.lane/memory/apps/host/src/move.ts/01M5AZZDBG1F569D8F4BWRV4PS-the-agent-s-push-runs-before.md @@ -0,0 +1,12 @@ +--- +id: 01M5AZZDBG1F569D8F4BWRV4PS +anchor: function push +created: 2026-10-19T21:09:43Z +norm: '1' +sig: 0b04ba9ef26235c0 +body_hash: bff338d65e17cfc5 +raw_hash: 5a490b1d9f3f458f +lines: 383-419 +--- + +the agent's push runs before the fence and outside `starting`, so its run stays watchable; a lane with no branch is refused, never sent to the agent diff --git a/.lane/memory/apps/host/src/move.ts/01M5AZZDC1TRZFJ8QCXFRSWH05-after-the-agent-s-run-the-re.md b/.lane/memory/apps/host/src/move.ts/01M5AZZDC1TRZFJ8QCXFRSWH05-after-the-agent-s-run-the-re.md new file mode 100644 index 00000000..fb1a2c3b --- /dev/null +++ b/.lane/memory/apps/host/src/move.ts/01M5AZZDC1TRZFJ8QCXFRSWH05-after-the-agent-s-run-the-re.md @@ -0,0 +1,12 @@ +--- +id: 01M5AZZDC1TRZFJ8QCXFRSWH05 +anchor: function move +created: 2026-10-19T21:09:43Z +norm: '1' +sig: 8cd687d651283c6b +body_hash: 2ff747cd385d660c +raw_hash: 3689b92e77a90616 +lines: 438-460 +--- + +after the agent's run the record is checked again with ready() before the fence, since the run can take minutes diff --git a/apps/app/src/move-channel.tsx b/apps/app/src/move-channel.tsx index 652f0376..973e1982 100644 --- a/apps/app/src/move-channel.tsx +++ b/apps/app/src/move-channel.tsx @@ -14,7 +14,7 @@ const OUTCOMES = { hosted: "The channel becomes a hosted channel there, with this host as its workspace. It keeps its chats, messages, and lanes.", cloud: - "Its tools then run in a container on the team's compute service, which starts from a fresh clone with the channel's pushed lanes; untracked files stay on this Mac. It keeps its chats and messages, and can't move back yet.", + "Its tools then run in a container on the team's compute service, which starts from a fresh clone with the channel's lanes. Its agent first pushes any lane with unpushed work; untracked files stay on this Mac. It keeps its chats and messages, and can't move back yet.", }; type Props = { diff --git a/apps/host/src/compute.ts b/apps/host/src/compute.ts index 87a1aadc..e5318136 100644 --- a/apps/host/src/compute.ts +++ b/apps/host/src/compute.ts @@ -55,6 +55,13 @@ export async function available(root: string): Promise { type Lane = { path: string; branch: string; base?: string }; +/** A lane a cloud workspace can't check out yet, and why. */ +export type Unready = { name: string; path: string; branch?: string; problem: string }; + +export function listed(lanes: Unready[]): string { + return lanes.map((lane) => `${lane.name} (${lane.problem})`).join(", "); +} + async function output(dir: string, args: string[]): Promise { const result = await git(dir, args); if (result.status !== 0) { @@ -64,7 +71,7 @@ async function output(dir: string, args: string[]): Promise { } /** A cloud workspace starts from a fresh clone, so lanes travel only as pushed branches. */ -async function lanes(root: string, prefix: string): Promise { +async function lanes(root: string, prefix: string): Promise<{ ready: Lane[]; unready: Unready[] }> { // Git lists real paths; the channel's records name lanes under its project path. const trees = `${realpathSync(root)}/.lane/trees/${prefix}/`; const found = (await output(root, ["worktree", "list", "--porcelain"])).split("\n\n").flatMap( @@ -80,7 +87,7 @@ async function lanes(root: string, prefix: string): Promise { }]; }, ); - if (!found.length) return []; + if (!found.length) return { ready: [], unready: [] }; const pushed = new Map( (await output(root, ["ls-remote", "origin", `refs/heads/${prefix}/*`])).split("\n") .filter(Boolean) @@ -89,34 +96,34 @@ async function lanes(root: string, prefix: string): Promise { return [ref!.slice("refs/heads/".length), commit]; }), ); - const unready: string[] = []; + const unready: Unready[] = []; const ready: Lane[] = []; - for (const lane of found) { + for (const { path, name, head, branch } of found) { + const blocked = (problem: string) => + unready.push({ name, path, ...(branch ? { branch } : {}), problem }); + if (!branch) { + blocked("no branch"); + continue; + } // lane clones the project's ignored files, such as node_modules, into worktrees whose branch may // not ignore them, so untracked files can't block a move; they stay on this host. - const changed = lane.branch - ? (await output(lane.path, ["diff", "--name-only", "HEAD"])).split("\n").filter(Boolean) - : []; - if (!lane.branch) unready.push(`${lane.name} (no branch)`); - else if (changed.length) { + const changed = (await output(path, ["diff", "--name-only", "HEAD"])).split("\n") + .filter(Boolean); + if (changed.length) { const more = changed.length > 3 ? `, and ${changed.length - 3} more` : ""; - unready.push( - `${lane.name} (uncommitted changes to ${changed.slice(0, 3).join(", ")}${more})`, - ); - } else if (pushed.get(lane.branch) !== lane.head) unready.push(`${lane.name} (not pushed)`); + blocked(`uncommitted changes to ${changed.slice(0, 3).join(", ")}${more}`); + } else if (pushed.get(branch) !== head) blocked("not pushed"); else { - const base = (await git(root, ["config", "--get", `lane.${lane.branch}.base`])).stdout.trim(); - ready.push({ path: lane.path, branch: lane.branch, ...(base ? { base } : {}) }); + const base = (await git(root, ["config", "--get", `lane.${branch}.base`])).stdout.trim(); + ready.push({ path, branch, ...(base ? { base } : {}) }); } } - if (unready.length) { - throw new Error( - `The cloud workspace starts from a fresh clone, so commit and push these lanes first, or remove them: ${ - unready.join(", ") - }.`, - ); - } - return ready; + return { ready, unready }; +} + +/** The channel's lanes that must be committed and pushed before it moves to a cloud workspace. */ +export async function unready(record: catalog.Listing): Promise { + return (await lanes(record.project, record.prefix)).unready; } /** @@ -128,6 +135,14 @@ export async function adoption(record: catalog.Listing) { if (!url || !(await available(record.project))) { throw new Error("Moving to a cloud workspace needs Cloud compute and a GitHub project."); } + const { ready, unready } = await lanes(record.project, record.prefix); + if (unready.length) { + throw new Error( + `The cloud workspace starts from a fresh clone, so commit and push these lanes first, or remove them: ${ + listed(unready) + }.`, + ); + } return { url, body: { @@ -139,7 +154,7 @@ export async function adoption(record: catalog.Listing) { named: record.named, model: record.model, project: record.project, - lanes: await lanes(record.project, record.prefix), + lanes: ready, }, }; } diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index 988db731..5e04ec0a 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -694,11 +694,14 @@ async function handle( const first = !moves.has(request.channel); moves.add(request.channel); try { - const run = () => move(request.channel, request.target, (waiting) => send({ id, waiting })); // A move to a cloud workspace ends by starting it, which the app must wait out like Run in - // cloud before it opens the chat. - if (request.target === "compute") await lifecycle(request.channel, "starting", run); - else await run(); + // cloud before it opens the chat; the agent's lane push before it stays watchable. + await move( + request.channel, + request.target, + (waiting) => send({ id, waiting }), + (run) => lifecycle(request.channel, "starting", run), + ); } finally { if (first) moves.delete(request.channel); // A failed move lifts its fence, which listings and reopened chats must see. diff --git a/apps/host/src/move.ts b/apps/host/src/move.ts index 13162ddd..f42af649 100644 --- a/apps/host/src/move.ts +++ b/apps/host/src/move.ts @@ -13,7 +13,7 @@ import type { ChannelInfo } from "@ace/channel/protocol"; import * as catalog from "./catalog"; import { Connection, hostedAuth } from "./client"; -import { adoption, available, start } from "./compute"; +import { adoption, available, listed, start, type Unready, unready } from "./compute"; import * as directory from "./directory"; import { refusal } from "./http"; import { KEYCHAIN_WAITING } from "./keys"; @@ -168,6 +168,12 @@ function stranded(error: unknown, where: string): Error { }); } +function remains(record: catalog.Listing): string { + return record.hosted + ? `The channel is still on ${new URL(record.hosted).host}.` + : "The channel is still on this Mac."; +} + /** Worker startup checks the fence after taking this lock, so holding it keeps storage still. */ async function park(id: string): Promise { if (isWorkerAlive(id)) { @@ -263,9 +269,7 @@ async function toCompute( waiting: (text: string | null) => void, ): Promise { const { id, hosted: source } = record; - const where = source - ? `The channel is still on ${new URL(source).host}.` - : "The channel is still on this Mac."; + const where = remains(record); let digest = record.moving?.digest; // A resumed move froze its source on the first attempt; thawing an active one is refused. let freezing = !!digest; @@ -359,6 +363,61 @@ async function toLocal(auth: Auth, record: catalog.Listing): Promise { catalog.unlock(id); } +function request(lanes: Unready[]): string { + return [ + "Ace is moving this channel to a cloud workspace. The workspace starts from a fresh clone, so it can only check out lanes whose branches are pushed to origin. These lanes aren't ready:", + "", + ...lanes.map((lane) => + `- ${lane.name}, branch ${lane.branch}, at ${lane.path}: ${lane.problem}` + ), + "", + "For each lane, commit its work if it has uncommitted changes, leaving out dependencies and build output such as node_modules, then run `lane push` in its worktree so its branch reaches origin. Don't open a pull request, and don't remove any lane. The move continues once this run ends with every lane pushed.", + ].join("\n"); +} + +/** + * The channel's agent usually did a lane's work, so it pushes unready lanes before a move to a + * cloud workspace, in a run people can watch. A lane without a branch has nothing to push, so + * the person removes it. + */ +async function push( + record: catalog.Listing, + waiting: (text: string | null) => void, +): Promise { + const lanes = await unready(record); + if (!lanes.length) return; + const orphans = lanes.filter((lane) => !lane.branch).map((lane) => lane.name); + if (orphans.length) { + throw new Error( + `These lanes have no branch to push, so remove them first: ${orphans.join(", ")}`, + ); + } + const names = lanes.map((lane) => lane.name).join(", "); + const connection = await Connection.open(record.id); + let status: string; + try { + const ask = { op: "ask", author: catalog.user, text: request(lanes) } as const; + const { submission } = await connection.request<{ submission: number }>(ask); + waiting(`Waiting for the agent to push ${names}…`); + status = await connection.request({ op: "wait", submission }); + } finally { + connection.close(); + waiting(null); + } + const left = await unready(catalog.read(record.id)); + if (status !== "done") { + const unpushed = left.length ? `; these lanes still need pushing: ${listed(left)}` : ""; + throw new Error(`The agent's run was stopped, so the move stopped${unpushed}`); + } + if (left.length) { + throw new Error( + `The agent's run ended without pushing every lane, so commit and push these or remove them: ${ + listed(left) + }`, + ); + } +} + /** The channel's record, if it can move to `to` now: "local", "compute", or a canonical service URL. */ function ready(id: string, to: string): catalog.Listing { const record = catalog.read(id); @@ -373,21 +432,29 @@ function ready(id: string, to: string): catalog.Listing { /** * Move a channel to `target`: "local", "compute", or a hosting service URL. The frozen source is - * kept, not deleted. + * kept, not deleted. `starting` wraps a move to a cloud workspace from its fence through the + * workspace's start, which the app waits out before it opens the chat again. */ export async function move( id: string, target: string, waiting: (text: string | null) => void = () => {}, + starting: (run: () => Promise) => Promise = (run) => run(), ): Promise { const to = target === "local" || target === COMPUTE ? target : canonical(target); - ready(id, to); + const checked = ready(id, to); + // A resumed move is already fenced, so its lanes were ready before. + if (to === COMPUTE && !checked.moving) { + await push(checked, waiting).catch((error) => { + throw stranded(error, remains(checked)); + }); + } const auth = await credentials(waiting); // The record may have changed during the wait; nothing from here awaits before the fence. const record = ready(id, to); log("info", "move.start", { channel: id, from: record.hosted || "local", to }); if (to === "local") await toLocal(auth, record); - else if (to === COMPUTE) await toCompute(auth, record, waiting); + else if (to === COMPUTE) await starting(() => toCompute(auth, record, waiting)); else await toHosted(auth, record, to); log("info", "move.done", { channel: id, to }); } diff --git a/docs/architecture.md b/docs/architecture.md index 7d36abbe..dc0e5e6a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -569,10 +569,17 @@ the same move retries activation. Watches on the old placement end and clients r When Cloud compute is available for the project, a local channel's menu, or a hosted channel's whose workspace is this host, also offers Move to cloud workspace (`ace move --compute`). -Its workspace starts from a fresh clone, so the host first refuses any of the channel's lanes that -have uncommitted or unpushed work. It then imports the snapshot through the compute service's -`import`, which stores the channel inactive with this host's project path and the lanes' branches, -activates it, saves the record as a stopped `compute` channel, and starts it. A local channel's +Its workspace starts from a fresh clone, so lanes travel only as pushed branches. When any lane has +uncommitted or unpushed work, the host first asks the channel's agent, in its first chat as the +owner, to commit each one and `lane push` it, without opening a pull request or removing lanes. The +move's progress waits for that run while the chat stays open, then the host checks the lanes again. +A stopped run or a lane still unready ends the move with the channel where it was, naming the lanes +left. A lane with no branch has nothing to push, so the move refuses it for the person to remove, +and a busy channel is refused before any of this. Only then does the host fence the channel, and +the listing reports `computing` from the fence until the workspace starts. The host imports the +snapshot through the compute service's `import`, which stores the channel inactive with this host's +project path and the lanes' branches, activates it, saves the record as a stopped `compute` +channel, and starts it. A local channel's snapshot comes from its parked store. A hosted channel's comes straight from its service, frozen and exported as in a move back, never through local storage; a failure before activation thaws it, and after activation the frozen object remains. The runner links that project path to its clone and