diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md b/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md
new file mode 100644
index 00000000..89f9f69c
--- /dev/null
+++ b/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md
@@ -0,0 +1,12 @@
+---
+id: 01M5AZ6NV8GAWASJKAVZPAMNM7
+anchor: function local
+created: 2026-10-19T20:56:13Z
+norm: '1'
+sig: 2bb79c92d28ae467
+body_hash: fa355c5e44da2b1a
+raw_hash: c5e60940ca9e03a5
+lines: 92-145
+---
+
+a move's fence names "local", "compute", or a service URL; only a URL may be parsed as one
diff --git a/.lane/memory/apps/host/src/move.ts/01M5314FQ002GKKHTQ5G9Q4NZJ-the-directory-s-hosting-serv.md b/.lane/memory/apps/host/src/move.ts/01M5314FQ002GKKHTQ5G9Q4NZJ-the-directory-s-hosting-serv.md
index f5625ef8..cb9eb7f1 100644
--- a/.lane/memory/apps/host/src/move.ts/01M5314FQ002GKKHTQ5G9Q4NZJ-the-directory-s-hosting-serv.md
+++ b/.lane/memory/apps/host/src/move.ts/01M5314FQ002GKKHTQ5G9Q4NZJ-the-directory-s-hosting-serv.md
@@ -4,9 +4,9 @@ anchor: function targets
created: 2026-10-16T18:56:03Z
norm: '1'
sig: 002f9c457b031ab4
-body_hash: 85d4201c6260eec4
-raw_hash: 8782a699c3cff424
-vouched: 2026-10-19T17:59:13Z
+body_hash: bab5cddb9dfa6ae8
+raw_hash: 73b7e221730eeb13
+vouched: 2026-10-19T20:56:20Z
lines: 64-79
---
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
new file mode 100644
index 00000000..5bf1362e
--- /dev/null
+++ b/.lane/memory/apps/host/src/move.ts/01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md
@@ -0,0 +1,12 @@
+---
+id: 01M5AZ6NMTVNC1WH65TAV3JB3W
+anchor: function toCompute
+created: 2026-10-19T20:56:11Z
+norm: '1'
+sig: 21af9e438a769213
+body_hash: 8574c71308234fbf
+raw_hash: beeaf7b2694ba561
+lines: 260-326
+---
+
+a hosted source is frozen and exported at its service, never read from the parked local store, and any failure before activation thaws it
diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts
index 5a57780f..988db731 100644
--- a/apps/host/src/gateway.ts
+++ b/apps/host/src/gateway.ts
@@ -134,7 +134,12 @@ async function local(): Promise
{
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;
+ const { to } = record.moving;
+ listing.moving = to === "local"
+ ? name
+ : to === "compute"
+ ? "cloud workspace"
+ : new URL(to).host;
}
return listing;
});
diff --git a/apps/host/src/move.ts b/apps/host/src/move.ts
index 8bb618b0..13162ddd 100644
--- a/apps/host/src/move.ts
+++ b/apps/host/src/move.ts
@@ -1,5 +1,5 @@
/**
- * Moving an idle channel between this host and a hosting service, or from this host to a cloud
+ * Moving an idle channel between this host and a hosting service, or from either to a cloud
* workspace. The channel keeps its ID and lanes; its pi store travels as one verified snapshot. A `moving` fence on the record keeps
* both copies from running while the move is under way, and an unknown activation keeps it.
*/
@@ -61,9 +61,11 @@ function movable(record: catalog.Listing): void {
if (record.busy) throw new Error("The channel is busy; stop its work before moving it");
}
-/** The target that moves a local channel to a cloud workspace on the team's compute service. */
+/** The target that moves a channel to a cloud workspace on the team's compute service. */
const COMPUTE = "compute";
+const WORKSPACE: MoveTarget = { target: COMPUTE, label: "cloud workspace", place: "cloud" };
+
/** `host` is this host's name as the app shows it, its tailnet machine name when it has one. */
export async function targets(id: string, host: string): Promise {
const record = catalog.read(id);
@@ -73,7 +75,8 @@ export async function targets(id: string, host: string): Promise {
return [];
}
if (record.hosted) {
- return [{ target: "local", label: `this host (${host})`, place: "local" }];
+ const local: MoveTarget = { target: "local", label: `this host (${host})`, place: "local" };
+ return await available(record.project) ? [local, WORKSPACE] : [local];
}
const team = directory.read().hosted;
const hosted = new Set([
@@ -87,7 +90,7 @@ export async function targets(id: string, host: string): Promise {
place: "hosted",
}));
if (!(await available(record.project))) return services;
- return [...services, { target: COMPUTE, label: "cloud workspace", place: "cloud" }];
+ return [...services, WORKSPACE];
}
/** A transfer whose answer never arrived may still have taken effect at the service. */
@@ -194,6 +197,22 @@ async function readLocal(id: string): Promise {
}
}
+/** A refused or unanswered freeze may still have frozen the service, so its caller thaws on failure. */
+async function exportHosted(auth: Auth, url: string, id: string): Promise {
+ const { digest } = await transfer<{ digest: string }>(auth, url, id, { action: "freeze" });
+ const snapshot = await transfer(auth, url, id, { action: "export" });
+ if (snapshot.digest !== digest) {
+ throw new Error("The exported snapshot does not match the frozen channel");
+ }
+ return snapshot;
+}
+
+async function thaw(auth: Auth, url: string, id: string): Promise {
+ await transfer(auth, url, id, { action: "thaw" }).catch((cause: Error) =>
+ log("warn", "move.thaw.failed", { channel: id, error: cause.message })
+ );
+}
+
async function toHosted(auth: Auth, record: catalog.Listing, url: string): Promise {
const { id } = record;
let digest = record.moving?.digest;
@@ -235,30 +254,41 @@ async function toHosted(auth: Auth, record: catalog.Listing, url: string): Promi
/**
* Like `toHosted`, except the compute service creates the workspace that adopts the channel, and
- * the channel stays stopped until its workspace starts.
+ * the channel stays stopped until its workspace starts. A hosted channel goes straight from its
+ * service to the compute service, frozen there like a move back to this host.
*/
async function toCompute(
auth: Auth,
record: catalog.Listing,
waiting: (text: string | null) => void,
): Promise {
- const { id } = record;
- const { url, body } = await adoption(record);
+ const { id, hosted: source } = record;
+ const where = source
+ ? `The channel is still on ${new URL(source).host}.`
+ : "The channel is still on this Mac.";
let digest = record.moving?.digest;
+ // A resumed move froze its source on the first attempt; thawing an active one is refused.
+ let freezing = !!digest;
+ const lift = async () => {
+ if (source && freezing) await thaw(auth, source, id);
+ catalog.write({ ...catalog.read(id), moving: undefined });
+ catalog.unlock(id);
+ };
+ const { url, body } = await adoption(record);
if (!digest) {
catalog.write({ ...record, moving: { to: COMPUTE } });
try {
await park(id);
- const snapshot = await readLocal(id);
+ freezing = true;
+ const snapshot = source ? await exportHosted(auth, source, id) : await readLocal(id);
if (Buffer.byteLength(JSON.stringify(snapshot)) > MAX_SNAPSHOT) {
throw new Error("The channel is too large to move yet");
}
await post(auth, `${url}/workspaces/${id}/import`, { ...body, snapshot });
digest = snapshot.digest;
} catch (error) {
- catalog.write({ ...catalog.read(id), moving: undefined });
- catalog.unlock(id);
- throw stranded(error, "The channel is still on this Mac.");
+ await lift();
+ throw stranded(error, where);
}
catalog.write({ ...catalog.read(id), moving: { to: COMPUTE, digest } });
}
@@ -271,9 +301,8 @@ async function toCompute(
"The channel may now be active there, so this copy stays fenced; run the same move again to finish it.",
);
}
- catalog.write({ ...catalog.read(id), moving: undefined });
- catalog.unlock(id);
- throw stranded(error, "The channel is still on this Mac.");
+ await lift();
+ throw stranded(error, where);
}
catalog.write({
...catalog.read(id),
@@ -307,11 +336,7 @@ async function toLocal(auth: Auth, record: catalog.Listing): Promise {
try {
await park(id);
freezing = true;
- const { digest } = await transfer<{ digest: string }>(auth, url, id, { action: "freeze" });
- const snapshot = await transfer(auth, url, id, { action: "export" });
- if (snapshot.digest !== digest) {
- throw new Error("The exported snapshot does not match the frozen channel");
- }
+ const snapshot = await exportHosted(auth, url, id);
for (const path of [incoming, `${incoming}-wal`, `${incoming}-shm`]) {
rmSync(path, { force: true });
}
@@ -325,11 +350,7 @@ async function toLocal(auth: Auth, record: catalog.Listing): Promise {
for (const path of [`${storage}-wal`, `${storage}-shm`]) rmSync(path, { force: true });
renameSync(incoming, storage);
} catch (error) {
- if (freezing) {
- await transfer(auth, url, id, { action: "thaw" }).catch((cause: Error) =>
- log("warn", "move.thaw.failed", { channel: id, error: cause.message })
- );
- }
+ if (freezing) await thaw(auth, url, id);
catalog.write({ ...catalog.read(id), moving: undefined });
catalog.unlock(id);
throw stranded(error, `The channel is still on ${new URL(url).host}.`);
@@ -344,7 +365,9 @@ function ready(id: string, to: string): catalog.Listing {
// Only an outbound move whose activation went unanswered resumes; anything else starts fresh.
if (!(record.moving?.digest && record.moving.to === to)) movable(record);
if (to === "local" && !record.hosted) throw new Error("The channel is already on this host");
- if (to !== "local" && record.hosted) throw new Error("Move the channel to this host first");
+ if (to !== "local" && to !== COMPUTE && record.hosted) {
+ throw new Error("Move the channel to this host first");
+ }
return record;
}
diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts
index cff8df37..919cc141 100644
--- a/apps/host/src/protocol.ts
+++ b/apps/host/src/protocol.ts
@@ -101,7 +101,10 @@ export type Listing = {
computing?: "starting" | "stopping";
/** The cloud compute workspace is stopped; the chat stays readable. Absent on older hosts. */
stopped?: true;
- /** Where a move under way is taking the channel: a hosting service's host, or this host's name. */
+ /**
+ * Where a move under way is taking the channel: a hosting service's host, "cloud workspace", or
+ * this host's name.
+ */
moving?: string;
/** Its peer host stopped answering while relaying a request or watch here; absent on older hosts. */
stalled?: boolean;
diff --git a/docs/architecture.md b/docs/architecture.md
index cd8b464f..7d36abbe 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -567,14 +567,17 @@ atomically replaces the parked one; the frozen object remains. A later move out
A failed step before activation lifts the fence. An unanswered activation keeps it, and repeating
the same move retries activation. Watches on the old placement end and clients reattach.
-When Cloud compute is available for the project, a local channel's menu 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. The runner links that project path to its clone and checks each lane's branch out
-at its old path, so the channel's records keep naming real paths. A start failure after activation
-leaves the channel moved and stopped, for Start compute to retry.
+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
+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
+checks each lane's branch out at its old path, so the channel's records keep naming real paths. A
+start failure after activation leaves the channel moved and stopped, for Start compute to retry.
## Shared services