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: 5 additions & 0 deletions .changeset/yield-to-tracked-subsessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---

Add explicit tracked-subsession yielding with no-poll wake-up guidance, remaining-child status, and clear boundaries around child output.
39 changes: 25 additions & 14 deletions docs/config.html
Original file line number Diff line number Diff line change
Expand Up @@ -525,24 +525,35 @@ <h3><code>spawnSessions</code></h3>
<h3><code>subsessions</code></h3>
<p>
Boolean. Beta. Controls whether agents receive the tracked-subsession tools:
<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, and
<code>read_subsession</code>. Defaults to <code>false</code> and also requires <code>spawnSessions</code>
to be enabled.
<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>,
<code>read_subsession</code>, and <code>yield_to_subsessions</code>. Defaults to <code>false</code> and also
requires <code>spawnSessions</code> to be enabled.
</p>
<p>
Tracked subsessions let an agent delegate work to child sessions, receive a notification when each child
stops working, and inspect their status and transcripts. Calling <code>spawn_subsession</code> returns
immediately. The parent can continue independent work while treating every child whose result it needs
as pending. Before producing work that depends on those results, the parent reaches a join point and
yields until every required child has sent a completion notice.
Tracked subsessions are join-oriented. Calling <code>spawn_subsession</code> returns immediately, so the
parent can continue independent work while the child runs. Work whose result the parent does not need to
join belongs in the fire-and-forget <code>spawn_session</code> tool instead.
</p>
<p>
A completion notice wakes an idle parent. If the parent is busy, the notice queues until the current
turn ends rather than interrupting in-flight work. For multiple required children, each notice resolves
one pending child; after processing it, the parent yields again if another required child is pending.
<code>list_subsessions</code>, <code>check_subsession</code>, and <code>read_subsession</code> provide
on-demand status and transcript inspection for deliberate progress checks or recovery. Completion
notifications, rather than polling these tools, are the normal synchronization mechanism.
At a join point, after finishing its independent work, the parent calls
<code>yield_to_subsessions</code> alone as the final action in its tool batch. Pi ends a tool batch early
only when every result in that batch is terminating. If any tracked child is still working, the action
ends the current agent run so the parent becomes idle. If none are working, it does not end the run and
clearly reports that there is nothing to wait for.
</p>
<p>
A completion notice wakes an idle parent or queues behind in-flight work. Each notice lists any other
tracked children still working, so the parent can continue work or call
<code>yield_to_subsessions</code> again at the next join point. Further notices arrive automatically; do
not poll.
</p>
<p>
<code>list_subsessions</code>, <code>check_subsession</code>, and <code>read_subsession</code> never yield
or change control flow. They are for deliberate inspection or recovery, not completion polling. While a
child works, agent-facing <code>check_subsession</code> and <code>read_subsession</code> withhold partial
output and direct the parent to continue independent work or yield at the join point. Output becomes
available when the child stops. In notices and inspection results, PI WEB guidance precedes a labeled
marker and the child output or transcript always comes last.
</p>
<p>
In <strong>Settings → Session daemon</strong>, these keys are saved on the selected machine. Restart the
Expand Down
10 changes: 7 additions & 3 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,15 @@ The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX

`spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions.

`subsessions` is beta and controls whether agents receive the tracked-subsession tools: `spawn_subsession`, `list_subsessions`, `check_subsession`, and `read_subsession`. It defaults to `false` and also requires `spawnSessions` to be enabled.
`subsessions` is beta and controls whether agents receive the tracked-subsession tools: `spawn_subsession`, `list_subsessions`, `check_subsession`, `read_subsession`, and `yield_to_subsessions`. It defaults to `false` and also requires `spawnSessions` to be enabled.

Tracked subsessions let an agent delegate work to child sessions, receive a notification when each child stops working, and inspect their status and transcripts. Calling `spawn_subsession` returns immediately. The parent can continue independent work while treating every child whose result it needs as pending. Before producing work that depends on those results, the parent reaches a join point and yields until every required child has sent a completion notice.
Tracked subsessions are join-oriented. Calling `spawn_subsession` returns immediately, so the parent can continue independent work while the child runs. Work whose result the parent does not need to join belongs in the fire-and-forget `spawn_session` tool instead.

A completion notice wakes an idle parent. If the parent is busy, the notice queues until the current turn ends rather than interrupting in-flight work. For multiple required children, each notice resolves one pending child; after processing it, the parent yields again if another required child is pending. `list_subsessions`, `check_subsession`, and `read_subsession` provide on-demand status and transcript inspection for deliberate progress checks or recovery. Completion notifications, rather than polling these tools, are the normal synchronization mechanism.
At a join point, after finishing its independent work, the parent calls `yield_to_subsessions` alone as the final action in its tool batch. Pi ends a tool batch early only when every result in that batch is terminating. If any tracked child is still working, the action ends the current agent run so the parent becomes idle. If none are working, it does not end the run and clearly reports that there is nothing to wait for.

A completion notice wakes an idle parent or queues behind in-flight work. Each notice lists any other tracked children still working, so the parent can continue work or call `yield_to_subsessions` again at the next join point. Further notices arrive automatically; do not poll.

`list_subsessions`, `check_subsession`, and `read_subsession` never yield or change control flow. They are for deliberate inspection or recovery, not completion polling. While a child works, agent-facing `check_subsession` and `read_subsession` withhold partial output and direct the parent to continue independent work or yield at the join point. Output becomes available when the child stops. In notices and inspection results, PI WEB guidance precedes a labeled marker and the child output or transcript always comes last.

In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ describe("delegation tool capability boundary", () => {
"list_subsessions",
"check_subsession",
"read_subsession",
"yield_to_subsessions",
]);
});

Expand Down
48 changes: 44 additions & 4 deletions src/server/sessions/piSessionService.spawnSubsession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionMa

describe("PiSessionService", () => {
describe("spawnSubsession", () => {
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) {
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000, childIds = ["child-1"]) {
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
const created = [parent.runtime, child.runtime];
const children = childIds.map((childId) => fakeRuntime(childId, {
sessionFile: `/tmp/${childId}.jsonl`,
sessionManager: fakeSessionManager("/workspace-feature"),
}));
const child = children[0];
if (child === undefined) throw new Error("At least one child fixture is required");
const created = [parent.runtime, ...children.map(({ runtime }) => runtime)];
let index = 0;
const createAgentRuntime: RuntimeCreator = async () => {
await Promise.resolve();
Expand All @@ -38,7 +43,7 @@ describe("PiSessionService", () => {
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
heartbeatIntervalMs,
});
return { parent, child, service };
return { parent, child, children, service };
}

it("records the parent, delivers the prompt, and lists the tracked child", async () => {
Expand Down Expand Up @@ -777,6 +782,41 @@ describe("PiSessionService", () => {
await service.dispose();
});

it("reports other working children in each completion notice", async () => {
const { parent, children, service } = subsessionService(
{ allowed: true, cwd: "/workspace-feature" },
60_000,
["child-1", "child-2"],
);
const [first, second] = children;
if (first === undefined || second === undefined) throw new Error("Expected two child fixtures");
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "first", cwd: "/workspace-feature" });
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "second", cwd: "/workspace-feature" });

first.session.isStreaming = true;
first.emit({ type: "agent_start" });
second.session.isStreaming = true;
second.emit({ type: "agent_start" });

first.session.isStreaming = false;
first.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));

expect(parent.calls.sendCustomMessage[0]?.message.content).toBe(
"Subsession child-1 stopped working (idle).\nStill working: child-2. Continue working, or call yield_to_subsessions alone and last at the next join point. Further completion notices arrive automatically; do not poll.\n\n--- SUBSESSION OUTPUT: child-1 ---\n(no output)",
);

second.session.isStreaming = false;
second.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));

expect(parent.calls.sendCustomMessage[1]?.message.content).toBe(
"Subsession child-2 stopped working (idle).\nNo other tracked subsessions are working.\n\n--- SUBSESSION OUTPUT: child-2 ---\n(no output)",
);
await service.dispose();
});

it("notifies via the heartbeat when the child settles without a further event", async () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10);
await service.start("/workspace");
Expand Down
16 changes: 15 additions & 1 deletion src/server/sessions/piSessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,16 @@ export class PiSessionService {
return "idle";
}

private workingSubsessionIds(parentSessionId: string): string[] {
const childIds = this.subsessionChildren.get(parentSessionId);
if (childIds === undefined) return [];
return [...childIds].filter((childId) => {
const link = this.subsessionLinks.get(childId);
const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
return active !== undefined && this.hasActiveWork(active.runtime.session);
});
}

/**
* Drive parent notifications from a tracked child's status. Arms a pending
* notification while the child is working, and when it stops fires a single
Expand All @@ -917,7 +927,11 @@ export class PiSessionService {
const status: SubsessionStatus = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
const finalText = finalAssistantText(historyMessages(session));
const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nStatus and latest output are available through check_subsession with sessionId "${childId}"; its full transcript is available through read_subsession.`;
const workingIds = this.workingSubsessionIds(link.parentSessionId);
const next = workingIds.length === 0
? "No other tracked subsessions are working."
: `Still working: ${workingIds.join(", ")}. Continue working, or call yield_to_subsessions alone and last at the next join point. Further completion notices arrive automatically; do not poll.`;
const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n--- SUBSESSION OUTPUT: ${childId} ---\n${preview}`;
void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
}

Expand Down
Loading