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
5 changes: 4 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ is one pi conversation in that Session.
- **Subagents are child chats.** A subagent's chat is owned by the parent's tool task, so killing
the parent kills the child. Agents in different channels only exchange messages.
- **Lanes are Git worktrees** created by the chat's agent for each unit of work. A chat's working
directory is its current lane.
directory is its current lane. A new lane starts from origin's advertised default branch, fetched
first, or from an explicit base, without changing the project checkout. Each lane records that
base as a ref or commit, and its Diff shows what a pull request into that base would. An explicit
base for an existing lane changes only that comparison.
- **Kill is durable.** Killing records pi's abort marks before the worker exits, so reopening the
channel does not resume the killed work.
- **Usage comes from pi's ledger.** A chat reports cumulative model and tool usage from
Expand Down
13 changes: 13 additions & 0 deletions docs/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ chats. Catalog names, summaries, and revision numbers are rebuildable listing pr
workers refresh them from committed metadata, and hosted channels send them to their workspace
on changes and reconnection. Renaming never moves an existing lane or changes its branch prefix.

Lanes live in pi's version 2 `ace.lanes` session document: each lane's chat, worktree path, and
base. A base is a full ref name, such as `refs/remotes/origin/main`, `refs/heads/release`, or
`refs/tags/v1`, or a commit when the requested base was an expression or a detached HEAD. pi
migrates version 1 documents on read, keeping every lane's chat and path; the version 2 form is
written on the next lane change. Version 1 lanes have no base, so their Diff compares against the
locally known `origin/HEAD`, or the project's HEAD in a repository without origin, until a lane
switch names a base.

The October 3, 2026 credential-deferral check opened a backed-up version 0 channel with no keys:
its pi entries and selected model were unchanged. Fresh channels accepted human messages before
provider setup and retained their history through a worker restart. Adding a real Anthropic key
Expand All @@ -100,3 +108,8 @@ channel backup. All original pi entries remained byte-for-byte equivalent, both
with a real model, and renamed channels and rolling summaries survived worker restarts. The source
backup's checksums remained unchanged. Fresh-channel checks also confirmed that renaming leaves
existing lane branches intact and new lanes use the original branch prefix.

The October 6, 2026 lanes check opened an isolated copy of an archived Canary channel backup with
version 1 `ace.lanes` data. All three chats and their lanes resolved, and every original pi entry
stayed byte-for-byte equivalent. A lane switch stored version 2 without a base, and after a worker
restart the channel continued with a real model. The source backup's checksums remained unchanged.
51 changes: 41 additions & 10 deletions packages/channel/src/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,45 @@ const read = (env: ExecutionEnv, cwd: string, args: string, context: Context, ok
git(env, `${args} 2>/dev/null`, context, { cwd, ...(ok ? { ok } : {}) });

/**
* The base a chat's work is measured against: where its lane branched from the project's HEAD, so
* commits the project made since don't show as the lane's. The checkout itself compares to HEAD.
* Lanes from before version 2 of `LanesDoc` recorded no base. They compare against origin's
* default branch as last fetched, since a Diff refresh must not fetch, or against the project's
* HEAD in a repository without origin.
*/
async function base(env: ExecutionEnv, cwd: string, project: string, context: Context) {
async function legacy(env: ExecutionEnv, cwd: string, project: string, context: Context) {
const remotes = (await read(env, cwd, "remote", context)).split("\n");
if (!remotes.includes("origin")) {
return (await read(env, project, "rev-parse HEAD", context)).trim();
}
const ref = (await read(env, cwd, "symbolic-ref -q refs/remotes/origin/HEAD", context, [0, 1]))
.trim();
const commit = ref
&& await read(env, cwd, `rev-parse -q --verify ${quote(`${ref}^{commit}`)}`, context, [0, 1]);
if (commit) return ref;
throw new Error(
"origin's default branch is unknown locally. Run `git fetch origin` and `git remote set-head origin --auto` in the project, or switch to the lane with a base.",
);
}

/**
* The base a chat's work is measured against: where its lane forked from the lane's base, as a
* pull request into that base would show it. The checkout itself compares to HEAD.
*/
async function base(
env: ExecutionEnv,
cwd: string,
project: string,
target: string | undefined,
context: Context,
) {
const head = (await read(env, cwd, "rev-parse HEAD", context)).trim();
if (cwd === project) return { base: head, head };
const main = (await read(env, project, "rev-parse HEAD", context)).trim();
const fork = (await read(env, cwd, `merge-base HEAD ${main}`, context)).trim();
return { base: fork, head };
const against = target || await legacy(env, cwd, project, context);
const fork = await read(env, cwd, `merge-base HEAD ${quote(against)}`, context).catch(() => {
throw new Error(
`Could not find where the lane forked from ${against}. Switch to the lane with another base.`,
);
});
return { base: fork.trim(), head };
}

/** `adds\tdels\tpath\0`, or `adds\tdels\t\0from\0to\0` for a rename; binary files count `-`. */
Expand Down Expand Up @@ -63,10 +93,10 @@ export async function changes(
env: ExecutionEnv,
cwd: string,
project: string,
lane: string | undefined,
lane: { name: string; base?: string } | undefined,
context: Context,
): Promise<Changes> {
const range = await base(env, cwd, project, context);
const range = await base(env, cwd, project, lane?.base, context);
const branch = (await read(env, cwd, "rev-parse --abbrev-ref HEAD", context)).trim();
const tracked = numstat(await read(env, cwd, `diff -M --numstat -z ${range.base}`, context));
const files = await Promise.all(
Expand All @@ -80,7 +110,7 @@ export async function changes(
}),
);
return {
...(lane ? { lane } : {}),
...(lane ? { lane: lane.name } : {}),
...(branch && branch !== "HEAD" ? { branch } : {}),
cwd,
...range,
Expand All @@ -93,13 +123,14 @@ export async function patch(
env: ExecutionEnv,
cwd: string,
project: string,
target: string | undefined,
file: string,
context: Context,
): Promise<string> {
if (!file || file.startsWith("/") || file.split("/").includes("..") || file.includes("\0")) {
throw new Error(`Invalid path ${file}`);
}
const { base: from } = await base(env, cwd, project, context);
const { base: from } = await base(env, cwd, project, target, context);
const known = await read(env, cwd, `ls-files -- ${quote(file)}`, context);
const historic = await read(
env,
Expand Down
15 changes: 10 additions & 5 deletions packages/channel/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,9 @@ export class Channel {
return changes(this.#options.env(cwd), cwd, this.#options.project, lane, context);
}
case "patch": {
const { cwd } = await this.#place(request.chat);
return patch(this.#options.env(cwd), cwd, this.#options.project, request.file, context);
const { cwd, lane } = await this.#place(request.chat);
const { env, project } = this.#options;
return patch(env(cwd), cwd, project, lane?.base, request.file, context);
}
case "wait": {
const submission = await this.#harness.submission(
Expand Down Expand Up @@ -278,12 +279,16 @@ export class Channel {
}

/** Where a chat works: its current lane's worktree, or the project checkout before it has one. */
async #place(chat: ChatId | undefined): Promise<{ cwd: string; lane?: string }> {
async #place(
chat: ChatId | undefined,
): Promise<{ cwd: string; lane?: { name: string; base?: string } }> {
const { id } = await this.#conversation(chat);
const cwd = (await this.#harness.snapshot(AgentDoc, id, context))?.cwd || this.#options.project;
const lanes = (await this.#harness.snapshot(LanesDoc, context))?.lanes || {};
const lane = Object.entries(lanes).find(([, value]) => value.path === cwd)?.[0];
return lane ? { cwd, lane } : { cwd };
const found = Object.entries(lanes).find(([, value]) => value.path === cwd);
if (!found) return { cwd };
const [name, { base }] = found;
return { cwd, lane: base ? { name, base } : { name } };
}

async #conversation(chat: ChatId | undefined): Promise<Conversation> {
Expand Down
17 changes: 11 additions & 6 deletions packages/channel/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ export function quote(value: string): string {
}

/**
* Runs git and returns stdout. Pathspecs are literal so file names can't act as globs, and reads
* take no optional locks so they never contend with the agent's own git commands.
* Runs git and returns stdout. Pathspecs are literal so file names can't act as globs, reads take
* no optional locks so they never contend with the agent's own git commands, and fetches fail
* rather than wait on a credential prompt no one can answer.
*/
export async function git(
env: ExecutionEnv,
Expand All @@ -16,10 +17,14 @@ export async function git(
options: { cwd?: string; ok?: number[] } = {},
): Promise<string> {
let output = "";
const result = await env.exec(`GIT_LITERAL_PATHSPECS=1 GIT_OPTIONAL_LOCKS=0 git ${args}`, {
...(options.cwd ? { cwd: options.cwd } : {}),
onOutput: (chunk) => (output += chunk),
}, context);
const result = await env.exec(
`GIT_LITERAL_PATHSPECS=1 GIT_OPTIONAL_LOCKS=0 GIT_TERMINAL_PROMPT=0 git ${args}`,
{
...(options.cwd ? { cwd: options.cwd } : {}),
onOutput: (chunk) => (output += chunk),
},
context,
);
if (!result.ok) throw result.error;
if (!(options.ok ?? [0]).includes(result.value.exitCode)) {
throw new Error(output.trim() || `git exited ${result.value.exitCode}`);
Expand Down
Loading