feat(p2): switch /tree branches while busy; subagents report to the asking branch

- Switching branches (navigate, select) no longer waits for the main conversation: a branch left while
  running keeps working in the background, read-only with Stop allowed.
- Branch rows in the sidebar show the working dot; background branches' answers count as unread.
- Fence refusals name a running background branch instead of a subagent #N.
- Subagent reports go to the conversation that sent the message; team send to "parent" goes to the
  conversation that last spawned or messaged the subagent.
This commit is contained in:
David Brailovsky 2026-11-04 18:13:57 +01:00
parent 4d72d05bcd
commit 0c5b7ee393
9 changed files with 118 additions and 49 deletions

View file

@ -40,13 +40,15 @@ version 2; absent: `{ 1, [{ 1, null }] }`):
list, `Sessions.fork`) uses `isTopLevel`, a document read every tool (`api`) and section (`input.read`) can do.
- **Current main** = `conversation`. It answers "which top-level conversation is shown and followed": the session row
(`main`; model and thinking level follow it), the web home route, the drawer, the chat's back links, TUI checks,
terminals, unread answers, `Sessions.fork`, peer messages (the conversations broker), and where subagents report.
terminals, reading unread answers, `Sessions.fork`, and peer messages (the conversations broker).
- Clients learn it through the session row (`Sessions` version 4, catalog schema 4; version 5: null labels); the
worker reports it after a switch and at every start (`treeSelf`). Clients built before still bind: the providers
accept `Sessions` 3 and 4 and `SessionTree` 1, mapping rows and the tree back (no `main`/`branches` for 3, a null
label as the session title for 4, no labels for `SessionTree` 1). A web page or TUI viewing the previous main conversation follows a switch.
- Earlier branches are read-only (`ReadOnlyConversations`): only the main conversation runs. The web shows only the
main conversation and subagents (others redirect to the main one).
- Earlier branches are read-only (`ReadOnlyConversations`): only the main conversation takes input (send, dequeue,
configure); Stop stays allowed. A branch left while it works keeps running in the background: its run and queued
input go on, its sidebar row shows the working dot, and its answer counts as unread. To watch or stop it, switch
back to it. The web shows only the main conversation and subagents (others redirect to the main one).
- **Labels** are stored per branch (see [Labels](#labels)).
## 2. Per-agent state
@ -55,7 +57,7 @@ The main agent's state stays on conversation 1, whichever branch it works in; a
| Document | Where |
| --- | --- |
| `p2.subagents` | Conversation 1, as before. Subagents are session-wide; their reports and `team send to: "parent"` go to the current main. TODO on `Subagents`: should probably become session-scoped (a migration). |
| `p2.subagents` | Conversation 1, as before. Subagents are session-wide. A subagent's report goes to the top-level conversation that sent the message (reporter input `parent`), and `team send to: "parent"` to the one that last spawned it or sent it a message (`Subagent.parent`), even after a `/tree` switch; records from before fall back to the current main. TODO on `Subagents`: should probably become session-scoped (a migration). |
| `p2.location-move` | Conversation 1: the move belongs to the session, whichever top-level conversation asked. |
| `p2.artifacts`, `voice.mode`, `chat-mode.settings` | `agentConversation(id)`: conversation 1 for a top-level conversation, the conversation itself for a subagent or one-shot agent. No copying at switches, no migration. |
@ -77,8 +79,10 @@ A branch forked before a subagent was spawned still lists that subagent (accepte
Where the session continues: the top-level conversation whose history ends there (the main one first; switching back
moves only `p2.main`), else a fork of the conversation storing that entry (same history, shorter chain than forking
the one it was picked in), else, before the first message, a new ownerless conversation with the agent as it was then.
The new conversation, its label and `p2.main` are one commit. Refused while the current main has a run or queued input;
subagents keep running.
The new conversation, its label and `p2.main` are one commit. Allowed while the current main has a run or queued
input: that work goes on in the branch left (see Top-level conversations), as do subagents. Shared per-agent state
(section 2) is then used by two runs at once; accepted. A subagent's report still goes to the branch that asked
(section 2).
### Labels

View file

@ -54,10 +54,13 @@ export default defineFacet((host) => {
0,
...messages.filter((message) => message.role === "assistant").map((message) => message.entryId),
);
// Unread answers are the main conversation's (conversation 1 or the `/tree` branch the session continues in).
// Unread answers are the top-level conversations' (the main one and the other `/tree` branches): a branch left
// while running answers in the background. `branches` is empty until the worker first reports it.
const row =
answer > (markers.value.answers[session] ?? 0) ? await catalog.get({ id: session }, ctx) : undefined;
if (
answer > (markers.value.answers[session] ?? 0) &&
conversation === (await catalog.get({ id: session }, ctx)).main
row &&
(conversation === row.main || row.branches.some((branch) => branch.conversation === conversation))
)
await markers.change((draft) => {
draft.answers[session] = answer;

View file

@ -109,6 +109,7 @@ const cleanTitle = (title: string): string => title.replace(/\s+/g, " ").trim();
* server's own (see `lifecycle`).
*/
async function fenceWorker(
catalog: Ref<typeof Catalog>,
children: Ref<typeof ChildStore>,
id: string,
doing: string,
@ -119,18 +120,28 @@ async function fenceWorker(
const result = await boundary.prepare(to ? { to } : null, root);
if (result.accepted) return undefined;
if (result.refusal !== undefined) return result.refusal;
const row = await catalog.get({ id }, root);
// A `/tree` branch left while running goes on in the background (docs/tree.md); conversation 1 is top-level before
// the worker first reports `branches`.
const branchOf = (conversation: number) => row.branches.find((branch) => branch.conversation === conversation);
const topLevel = (conversation: number) => conversation === 1 || branchOf(conversation) !== undefined;
const branches = result.busy
.filter((conversation) => topLevel(conversation) && conversation !== row.main)
.map((conversation) => `"${branchOf(conversation)?.label ?? row.title}"`);
const agents = result.busy
.filter((id) => id !== 1)
.filter((conversation) => !topLevel(conversation))
.map(
(conversation) =>
children.children.value[id]?.find((child) => child.conversationId === conversation)?.name ??
`#${conversation}`,
);
return result.busy.includes(1)
return result.busy.includes(row.main)
? `Stop the current run before ${doing}`
: agents.length
? `Subagent ${agents.join(", ")} is still running: stop it before ${doing}`
: `Background work is still running (for example a message waiting for a subagent's answer): wait for it or stop the subagent before ${doing}`;
: branches.length
? `Branch ${branches.join(", ")} is still running: switch to it and stop it before ${doing}`
: agents.length
? `Subagent ${agents.join(", ")} is still running: stop it before ${doing}`
: `Background work is still running (for example a message waiting for a subagent's answer): wait for it or stop the subagent before ${doing}`;
}
export default defineFacet((host) => {
@ -239,7 +250,7 @@ export default defineFacet((host) => {
const boundary = () => root.get(WorkerBoundary, { session: id });
return server().workers.relocate(id, {
prepare: async () => {
const refusal = await fenceWorker(children, id, "changing directory", root, location);
const refusal = await fenceWorker(catalog, children, id, "changing directory", root, location);
refused = refusal ?? null;
return refusal;
},
@ -320,7 +331,7 @@ export default defineFacet((host) => {
const server = self.get(Server, {});
const moves = self.get(SessionMoves, {});
const fence = (id: string, doing: string, root: Ctx, to?: Location) =>
fenceWorker(children, id, doing, root, to);
fenceWorker(catalog, children, id, doing, root, to);
/**
* Runs a lifecycle operation's steps in a root of the server's own, not the caller's: a caller giving up (an
* aborted call, a closed tab) must not cut a fenced worker off between its steps.

View file

@ -46,6 +46,11 @@ export type Subagent = {
* environment it was spawned on before the Harness is live, so no one else reads it.
*/
unplacedCwd?: string;
/**
* The top-level conversation that last spawned it or sent it a message (the main conversation or a `/tree` branch):
* where `team send to: "parent"` goes. Absent (spawned before it was recorded): the current main conversation.
*/
parent?: ConversationId;
/** Answers already reported: several messages can end in one answer, reported once. */
reported: EntryId[];
/** Hidden from the sidebar's list and `status`; still usable, and `send` restores it. */

View file

@ -129,11 +129,16 @@ async function latestAnswer(api: ToolExecutionApi, conversationId: ConversationI
}, context);
}
// Delivers one message, waits for the answer, then reports it to the main agent
// Delivers one message, waits for the answer, then reports it to the conversation that sent the message
// as a follow-up input. Request IDs make both submissions exactly-once across restarts.
type ReporterInput = {
name: string;
conversationId: ConversationId;
/**
* The conversation that sent the message, which the report goes to: a `/tree` branch left while the subagent works
* still gets its answer. Absent (checkpointed before it was recorded): the current main conversation.
*/
parent?: ConversationId;
message: string;
followUp: boolean;
/** The owner started the main agent's turn: the subagent's turn inherits it (`SUBAGENT_TASK_ENTRY`). */
@ -181,10 +186,9 @@ const Reporter = defineTask<ReporterInput, ReporterState, null>({
report: async (reporter, runtime, context) => {
const report = reporter.state.checkpoint.report;
if (report !== undefined) {
const { name, conversationId } = reporter.input;
// To the current main conversation: a `/tree` switch while the subagent worked moves where its answer goes.
const main = await runtime.conversation(await mainConversation(runtime, context), context);
await main?.submit(
const { name, conversationId, parent } = reporter.input;
const target = await runtime.conversation(parent ?? (await mainConversation(runtime, context)), context);
await target?.submit(
{
type: "input",
...(typeof report === "string"
@ -555,6 +559,7 @@ export function createSubagentExtension(models: Models, locator: SubagentLocator
if (!target) return `No subagent named ${name}.`;
// Working with an archived subagent again brings it back.
delete target.archived;
target.parent = api.conversationId;
// Subagents spawned before the team or history tools existed get them now.
if (!target.team || !target.history) {
const all = installed();
@ -566,6 +571,7 @@ export function createSubagentExtension(models: Models, locator: SubagentLocator
const input = {
name,
conversationId: target.conversationId,
parent: api.conversationId,
message: message ?? "",
followUp: action === "send" && followUp === true,
...(origin === "owner" ? { origin } : {}),
@ -660,7 +666,11 @@ const teamTool: ToolRegistration = defineTool({
if (!args.message?.trim()) return reply("send needs a message.", true);
const sibling = args.to === "parent" ? undefined : siblings.find(([name]) => name === args.to)?.[1];
if (args.to !== "parent" && !sibling) return reply(`No other subagent named ${args.to}.`, true);
const target = await api.conversation(sibling?.conversationId ?? (await mainConversation(api, context)), context);
const parent = registry?.agents[self]?.parent;
const target = await api.conversation(
sibling?.conversationId ?? parent ?? (await mainConversation(api, context)),
context,
);
if (!target) return reply(`${args.to} has no conversation.`, true);
await target.submit(
{

View file

@ -57,14 +57,14 @@ const SessionTreeV1 = remote("p2.tree", {
/**
* Continues the session at `entry` (pi's `/tree`): after it for an answer, before it for a user message, whose
* text comes back for the editor (null otherwise). Switches to the branch ending there, else forks a new one
* (a new empty conversation before the first message), labelled with the chosen message's start. Refused
* while the main conversation works. Returns the new main conversation.
* (a new empty conversation before the first message), labelled with the chosen message's start. A run of
* the conversation left goes on in the background. Returns the new main conversation.
*/
navigate: method(
obj({ entry: int({ minimum: 1 }) }),
obj({ conversation: int({ minimum: 1 }), text: union([str(), nul()]) }),
),
/** Continues the session at the end of the branch `conversation` (a sidebar row); refused while busy. */
/** Continues the session at the end of the branch `conversation` (a sidebar row); a run goes on. */
select: method(obj({ conversation: int({ minimum: 1 }) })),
},
},

View file

@ -60,7 +60,8 @@ const NOT_CURRENT = "This is an earlier branch of the session: switch to it with
export default defineFacet((host) => {
contribute(host, "p2.tree-branches", (self) => {
const harness = harnessOf(self);
// Only the current main conversation runs: an earlier branch shows its history but takes no input.
// Only the current main conversation takes input: an earlier branch shows its history, and a run it still has goes
// on in the background (Stop stays allowed).
self.contribute(ReadOnlyConversations, {
tree: async (conversation, ctx) => {
const durable = harness().durable;
@ -293,7 +294,8 @@ function assistantText(entry: EntryRecord): string {
* text comes back). A top-level conversation already ending there is reused, its label unchanged; else a fork of the
* conversation storing the point, or a new empty conversation before the first message, labelled with the chosen
* message (what the owner picked: the answer, or the user message typed again). The new conversation, its label, the
* carried documents and the main pointer are one commit.
* carried documents and the main pointer are one commit. A run of the conversation left keeps going in the background,
* read-only.
*/
async function navigate(
durable: Harness,
@ -301,7 +303,6 @@ async function navigate(
context: Context,
): Promise<{ conversation: number; text: string | null }> {
const main = await mainConversation(durable, context);
await idleOrRefuse(durable, main, context);
const chosen = await durable.commit((tx) => tx.entry(entry), context);
if (!chosen || !(await isTopLevel(durable, chosen.conversationId, context))) throw new Error("Unknown message");
const role = roleOf(chosen);
@ -339,22 +340,10 @@ async function navigate(
const OWNERLESS = { ownership: { kind: "ownerless" } } as const;
/** Refuses a switch while the main conversation works: a run, queued input, or a task of its own. */
async function idleOrRefuse(durable: Harness, main: ConversationId, context: Context): Promise<void> {
const inspection = await durable.inspect(context);
if (
inspection.tasks.some((task) => task.record.conversationId === main && !task.record.background) ||
inspection.submissions.some((submission) => submission.conversationId === main)
)
throw new Error("Stop the current run before switching branches");
}
/** Switches the main conversation to the top-level conversation `id` (its latest message), refused while busy. */
/** Switches the main conversation to the top-level conversation `id` (its latest message); a run there goes on. */
async function select(durable: Harness, id: ConversationId, context: Context): Promise<void> {
if (!(await isTopLevel(durable, id, context))) throw new Error("Unknown branch");
const main = await mainConversation(durable, context);
if (id === main) return;
await idleOrRefuse(durable, main, context);
if (id === (await mainConversation(durable, context))) return;
await durable.commit(async (tx) => {
(await tx.doc(MainDoc)).conversation = id;
}, context);

View file

@ -73,7 +73,8 @@ function ThemeButton() {
/**
* A session's `/tree` branches (docs/tree.md), newest first in a stable order: switching only moves the "current" mark.
* Shown from two branches on, behind the session row's expand control (`hidden` while collapsed). Choosing another branch switches the session to it (`onBranch`, the owner's); the pencil beside a
* row renames it (`onRenameBranch`). A branch without a label (conversation 1's default) shows the session title.
* row renames it (`onRenameBranch`). A branch without a label (conversation 1's default) shows the session title. A
* branch left while running goes on in the background: its row shows the working dot.
*/
function BranchRows({
session,
@ -111,6 +112,9 @@ function BranchRows({
<BranchIcon size={13} />
<span className="session-title">{label}</span>
{current && <span className="session-meta">current</span>}
{session.worker.busy?.includes(branch.conversation) && (
<span className="live-dot" title="Working" />
)}
</button>
{onRenameBranch && (
<button

View file

@ -1,6 +1,6 @@
// `/tree` (docs/tree.md) on the real server and session worker with the fake provider: the tree of the main
// conversation and its branches, switching (forking, reusing a branch, before the first message), the session row's
// `main`, read-only earlier branches, main-only tools in a branch, the refusals, branch labels (stored at the fork,
// `main`, read-only earlier branches, main-only tools in a branch, switching while busy, branch labels (stored at the fork,
// renamed, conversation 1's null for the session title) and `p2.main` version 1's migration.
import assert from "node:assert/strict";
@ -167,7 +167,7 @@ describe("/tree (fake provider)", () => {
}
});
it("registers a branch's subagents on conversation 1 and reports to the current main", async () => {
it("registers a branch's subagents on conversation 1 and reports to the branch", async () => {
const tree = await (await treeRef()).tree(BACKGROUND_CONTEXT);
const chat = await chatOf(tree.main);
await chat.send({ text: "subagent", requestId: "subagent" }, BACKGROUND_CONTEXT);
@ -182,6 +182,39 @@ describe("/tree (fake provider)", () => {
feed.release();
});
it("reports to the branch that asked, also after a switch; team send to parent goes there too", async () => {
const ref = await treeRef();
const asking = (await ref.tree(BACKGROUND_CONTEXT)).main;
const chat = await chatOf(asking);
const spawn = { action: "spawn", name: "slowpoke", message: "slow-tool", tools: ["bash"] };
await chat.send({ text: `subagent ${JSON.stringify(spawn)}`, requestId: "slowpoke" }, BACKGROUND_CONTEXT);
await until(chat.view, (next) => text(next).includes("Started slowpoke."));
// The subagent's bash call takes 4 seconds: the session switches meanwhile.
await ref.select({ conversation: 1 }, BACKGROUND_CONTEXT);
await until(chat.view, (next) => text(next).includes("[subagent slowpoke answered"));
const feed = await holdIn(owner, Subagents, {}, "reader");
const children = await until(
feed.children,
(value) => value?.[session.id]?.some((child) => child.name === "slowpoke") ?? false,
);
feed.release();
const slowpoke = children?.[session.id]?.find((child) => child.name === "slowpoke")?.conversationId;
assert(slowpoke !== undefined);
const direct = await chatOf(slowpoke);
const team = { action: "send", to: "parent", message: "for the asker", intent: "note" };
await direct.send({ text: `team ${JSON.stringify(team)}`, requestId: "team-parent" }, BACKGROUND_CONTEXT);
await until(chat.view, (next) => text(next).includes("[subagent slowpoke notes, no reply needed] for the asker"));
direct.release();
chat.release();
await idle();
const main = await chatOf(1);
const view = await until(main.view, (next) => next !== null && next !== undefined);
assert.doesNotMatch(text(view), /subagent slowpoke/);
main.release();
await ref.select({ conversation: asking }, BACKGROUND_CONTEXT);
ref.release();
});
it("lists the branches in the session row and switches with select", async () => {
const sessions = await sessionsRef();
const rows = await until(
@ -288,19 +321,29 @@ describe("/tree (fake provider)", () => {
assert.equal((await ref.tree(BACKGROUND_CONTEXT)).branches[1]?.label, "Second try");
});
it("refuses while the main conversation works", async () => {
it("switches while the main conversation works; the branch left runs on, read-only, and stops", async () => {
const ref = await treeRef();
const tree = await ref.tree(BACKGROUND_CONTEXT);
const chat = await chatOf(tree.main);
const left = tree.main;
const chat = await chatOf(left);
await chat.send({ text: "slow answer", requestId: "slow" }, BACKGROUND_CONTEXT);
await until(chat.view, (next) => Boolean((next?.docs["pi.live"] as { run?: unknown } | undefined)?.run));
await ref.select({ conversation: 1 }, BACKGROUND_CONTEXT);
const sessions = await sessionsRef();
await until(sessions.sessions, (list) => list.find((each) => each.id === session.id)?.main === 1);
const status = await holdIn(owner, SessionStatus, {}, "reader");
await until(status.statuses, (value) => value?.[session.id]?.busy.includes(left) ?? false);
status.release();
await assert.rejects(
ref.navigate({ entry: nodeWith(tree, "user", "first").entry }, BACKGROUND_CONTEXT),
/Stop the current run/,
chat.send({ text: "no", requestId: "no-left" }, BACKGROUND_CONTEXT),
/earlier branch of the session/,
);
await chat.stop(BACKGROUND_CONTEXT);
await until(chat.view, (next) => !(next?.docs["pi.live"] as { run?: unknown } | undefined)?.run);
chat.release();
await idle();
await ref.select({ conversation: left }, BACKGROUND_CONTEXT);
await until(sessions.sessions, (list) => list.find((each) => each.id === session.id)?.main === left);
await assert.rejects(ref.navigate({ entry: 999_999 }, BACKGROUND_CONTEXT), /Unknown message/);
ref.release();
});