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
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion apps/app/src/move-channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
63 changes: 39 additions & 24 deletions apps/host/src/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ export async function available(root: string): Promise<boolean> {

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<string> {
const result = await git(dir, args);
if (result.status !== 0) {
Expand All @@ -64,7 +71,7 @@ async function output(dir: string, args: string[]): Promise<string> {
}

/** A cloud workspace starts from a fresh clone, so lanes travel only as pushed branches. */
async function lanes(root: string, prefix: string): Promise<Lane[]> {
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(
Expand All @@ -80,7 +87,7 @@ async function lanes(root: string, prefix: string): Promise<Lane[]> {
}];
},
);
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)
Expand All @@ -89,34 +96,34 @@ async function lanes(root: string, prefix: string): Promise<Lane[]> {
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<Unready[]> {
return (await lanes(record.project, record.prefix)).unready;
}

/**
Expand All @@ -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: {
Expand All @@ -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,
},
};
}
Expand Down
11 changes: 7 additions & 4 deletions apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading