diff --git a/apps/p2/docs/tree.md b/apps/p2/docs/tree.md index 0a557957d..16a3b42af 100644 --- a/apps/p2/docs/tree.md +++ b/apps/p2/docs/tree.md @@ -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 diff --git a/apps/p2/plugins/search/server.ts b/apps/p2/plugins/search/server.ts index 274d16829..6c1bf10a7 100644 --- a/apps/p2/plugins/search/server.ts +++ b/apps/p2/plugins/search/server.ts @@ -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; diff --git a/apps/p2/plugins/sessions/server.ts b/apps/p2/plugins/sessions/server.ts index 6b3ac2bd5..88105f7d3 100644 --- a/apps/p2/plugins/sessions/server.ts +++ b/apps/p2/plugins/sessions/server.ts @@ -109,6 +109,7 @@ const cleanTitle = (title: string): string => title.replace(/\s+/g, " ").trim(); * server's own (see `lifecycle`). */ async function fenceWorker( + catalog: Ref, children: Ref, 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. diff --git a/apps/p2/plugins/subagents/contract.worker.ts b/apps/p2/plugins/subagents/contract.worker.ts index df3b2e21f..383baf539 100644 --- a/apps/p2/plugins/subagents/contract.worker.ts +++ b/apps/p2/plugins/subagents/contract.worker.ts @@ -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. */ diff --git a/apps/p2/plugins/subagents/tools.ts b/apps/p2/plugins/subagents/tools.ts index 8766818a1..33d9ef70a 100644 --- a/apps/p2/plugins/subagents/tools.ts +++ b/apps/p2/plugins/subagents/tools.ts @@ -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({ 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( { diff --git a/apps/p2/plugins/tree/contract.ts b/apps/p2/plugins/tree/contract.ts index 2e0e2ad6c..cb0597915 100644 --- a/apps/p2/plugins/tree/contract.ts +++ b/apps/p2/plugins/tree/contract.ts @@ -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 }) })), }, }, diff --git a/apps/p2/plugins/tree/worker.ts b/apps/p2/plugins/tree/worker.ts index 2977dab6e..a2a15111a 100644 --- a/apps/p2/plugins/tree/worker.ts +++ b/apps/p2/plugins/tree/worker.ts @@ -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 { - 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 { 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); diff --git a/apps/p2/src/web/Drawer.tsx b/apps/p2/src/web/Drawer.tsx index af88549b2..71249746f 100644 --- a/apps/p2/src/web/Drawer.tsx +++ b/apps/p2/src/web/Drawer.tsx @@ -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({ {label} {current && current} + {session.worker.busy?.includes(branch.conversation) && ( + + )} {onRenameBranch && (