Pi Subagents · Field Guide
Reference
M04

Chapter 4: The Fleet — Lifecycle and Concurrency

1,785 words · 30 code lines · 9 min

A Pi AgentSession can run one conversation: accept a prompt, call the model, execute tools, emit events, and retain history. A fleet needs a different kind of machinery. It must answer: Which sessions exist? Which have started? Which are waiting? Who owns each nested child? When a run ends, which waiting run may start next?

pi-subagents does not replace Pi’s agent loop to answer those questions. It places an orchestration layer around real Pi sessions. The central objects are a mutable AgentRecord for each run and one AgentManager that owns their lifecycle and the top-level background queue.

Source lock: this chapter describes pi-subagents 0.18.0 at commit 3f9d35c and Pi 0.84.2 at commit 914cf14. The distinction matters: Pi owns the session runtime; pi-subagents owns fleet policy. All implementation links below are pinned to these commits.


Recall: what does Pi already provide?

Before reading on, retrieve the previous chapter’s model from memory:

  1. What object accepts the child’s prompt?
  2. Who executes the child’s tools and emits message, turn, and tool events?
  3. Does pi-subagents need a second agent loop?

The compact answer is: Pi’s SDK creates an AgentSession; session.prompt() drives its existing agent loop; session.subscribe() exposes the event stream. The Pi SDK contract accepts the model, tools, resource loader, settings, and session manager, then returns the session. See Pi’s pinned CreateAgentSessionOptions and result](https://github.com/earendil-works/pi/blob/914cf1472e715297caa30db4b9535d534a9eb718/packages/coding-agent/src/core/sdk.ts#L38-L97) and [createAgentSession()` entry point. The fleet layer reuses that runtime; it does not recreate it.

Two levels of state

An AgentSession knows the conversation. An AgentRecord knows how that conversation participates in the fleet.

The record groups several kinds of state:

ConcernRepresentative fields
Identityid, type, handle, description
Lifecyclestatus, startedAt, completedAt, result, error
Live controlsession, promise, abortController, pendingSteers
AccountingtoolUses, lifetimeUsage, compactionCount
Topologydepth, parentAgentId, maxSubagentDepth, rootSessionId
DeliveryisBackground, resultConsumed, groupId, joinMode, outputFile

The complete contract is visible in AgentRecord. It is deliberately a control block, not a copy of the child’s full message history. The session field points to Pi’s live object; result is only the output selected for return or notification.

AgentManager owns the map of records and the lifecycle transitions that must agree with the queue: agents, queue, runningBackground, and maxConcurrent live together. That co-location is the important design choice. If a UI component independently decided that a run had finished, it could display the right icon while leaving the queue permanently blocked. Outer layers may attach transcript and notification metadata to a record, but the manager owns status changes, slot accounting, aborts, and queue draining. See the manager’s owned fields and constructor.

The three-phase lifecycle: spawn, start, settle

Think of a launch as three separate operations rather than one large await.

1. Spawn: create an addressable fact

spawn() validates a caller-supplied working directory, creates an ID and abort controller, constructs the record, and inserts it into the manager’s map. A background record is initially marked queued; a foreground record is initially running. Only after the record exists does the manager decide whether it can start immediately. This means a queued task is visible and controllable even though it has no Pi session or execution promise yet. The exact ordering is in spawn().

2. Start: acquire runtime resources

startAgent() revalidates the working directory, performs synchronous worktree setup if requested, changes the record to running, takes a pool slot when applicable, and fires the start callback. It then calls runAgent().

runAgent() is the seam back into Pi: it builds the child resource loader and session manager, calls Pi’s createAgentSession(), binds extensions, subscribes to Pi events, and finally awaits session.prompt(). pi-subagents translates those events into fleet counters; Pi still performs the actual conversation and tool loop. See the extension’s session construction and prompt boundary and Pi’s pinned prompt() streaming contract.

3. Settle: publish one terminal outcome

For a fresh spawn, “settle” is a conceptual phase implemented by the promise’s .then() and .catch(), not a method named settle(). It stores result or error, stamps completedAt, closes streaming output, cleans the worktree, stops owned children, and sends the completion callback. A background settlement decrements the counter only if the record occupied a slot, then calls drainQueue(); a foreground settlement does neither slot accounting nor queue draining. When a slot is released, it is released before the queue is drained. See the fresh-run settlement paths and drainQueue().

State diagram, in text

spawn request
  |
  +-- validation fails ----------------------------> throw; no record
  |
  `-- record inserted
       |
       +-- foreground / exempt / slot available ---> running
       |                                                |
       |                                                +--> completed
       |                                                +--> steered
       |                                                +--> aborted
       |                                                +--> error
       |                                                `--> stopped (external abort)
       |
       `-- top-level background pool full ----------> queued
                                                        |
                                                        +--> running (drainQueue)
                                                        +--> error (late start failure)
                                                        `--> stopped (abort before start)
AgentRecord states and the queue boundary around top-level background work
AgentRecord states and the queue boundary around top-level background work

Notice one subtlety: a queued record has no promise until drainQueue() starts it. Any “wait for result” feature must first wait for queued to change, then await the promise. The real tool-level regression test pins that behavior in wait-queued.test.ts.

One pool, with a precise membership rule

The pool is not “all agents.” A record occupies a slot exactly when it is both:

background AND top-level

In code, that is !!record.isBackground && record.parentAgentId === undefined. Foreground runs bypass the pool because their tool call already blocks the parent. Nested runs bypass it for a more important liveness reason, covered next. The rule is centralized in occupiesPoolSlot(), and the same predicate guards both increment and decrement.

The owning constant sets the default top-level background concurrency to 10, and a fresh-module test asserts 10. However, the file header still says “default: 4.” That header is stale documentation, not runtime truth. Prefer the owning constant and rationale plus the default regression test. Ten was chosen when ordinary top-level launches changed to background by default; the old limit of four would have queued the tail of a common six-agent fan-out.

Predict: a four-spawn timeline

Temporarily set maxConcurrent = 2. Then issue these launches in order:

A: top-level background, long-running
B: top-level background, long-running
C: top-level background
D: foreground

Before revealing the answer, predict each status and the value of runningBackground. Then predict what happens when D finishes, followed by A.

Reveal the timeline
after A: A running                         pool = 1
after B: A running, B running              pool = 2
after C: A running, B running, C queued    pool = 2
after D: A/B/D running, C queued           pool = 2
D ends:   A/B running, C still queued      pool = 2
A ends:   B/C running                      pool = 2

D starts immediately because foreground bypasses admission and never increments the background counter. Its completion therefore cannot free a slot. A occupied a slot; when A settles, the manager decrements to 1 and drainQueue() starts C, returning the counter to 2.

Why nested children do not take top-level slots

Suppose the pool limit is one. Parent P is a top-level background run and holds the only slot. P launches child C and waits for C’s answer. If C were put behind the same queue, it could start only after P settled; P cannot settle until C returns. Both wait forever.

The fix is structural: a record with parentAgentId never occupies the top-level pool, even when that nested child was explicitly launched in the background. The regression test constructs exactly this case: the child runs while a second top-level sibling remains queued. See the deadlock test.

This exemption is a liveness rule, not a global concurrency guarantee. The depth cap bounds how far delegation can descend, but it does not bound how many children a parent may fan out. Nested launches default to foreground, though an explicit background request is allowed. When a parent settles, the manager marks direct active children stopped and aborts their controllers. Once abort forwarding is attached, cancellation reaches the child session and can propagate on settlement; Chapter 7 examines a pre-session race that prevents treating this as an unconditional “cannot outlive” guarantee.

Bounded sidebar — scheduled bypass. A scheduled fire calls the same manager.spawn() with isBackground: true and bypassQueue: true, so a timer is not delayed behind long manual work. The flag skips the admission check; it does not create a second scheduler runtime or a separately bounded pool. The run still increments runningBackground, and overlapping scheduled fires can temporarily take the count above maxConcurrent. This is a narrow liveness exception, not a claim that total concurrency is ten. See executeJob().

The startup/failure boundary

There are three distinct failure times, and collapsing them loses information.

  1. Before a run starts: invalid cwd fails before insertion. A synchronous start failure, such as worktree creation, removes the just-created record and rethrows. The Agent tool deliberately lets that exception escape.
  2. While a queued run starts later: the caller already received an ID, so failure cannot be returned through the original tool call. drainQueue() marks that record error, stamps completion, invokes the callback, and continues draining rather than stranding later tasks.
  3. After Pi owns a live run: promise rejection, hard abort, or a resolved final-turn provider failure is normalized into the record’s terminal status. Partial output may remain in result, but it is not mislabeled as completion.

Why must a true startup failure throw? At the pinned Pi host boundary, a successful return from tool.execute() is classified with isError: false; a thrown exception is caught and classified with isError: true. See Pi’s executePreparedToolCall(). Merely returning diagnostic text would look like a successful subagent report and may invite a pointless retry.

After start, consumers must inspect record.status, not assume that promise resolution means success. The manager’s catch path records the error and returns an empty string, so the tracked promise intentionally settles. The scheduler follows this contract by reading terminal status after the promise resolves.

Group join is delivery policy, not execution policy

Group join never starts an agent, consumes a slot, or blocks drainQueue(). It only holds completed records so the parent receives one consolidated notification. All results deliver immediately when the last member finishes; otherwise the first completion starts a 30-second timer, after which finished records deliver as a partial batch and stragglers are re-batched with a 15-second timeout. Keep this layer mentally downstream of settlement. See GroupJoinManager.onAgentComplete().

Practice

Trace the following without running code: pool limit one; P is a running top-level background parent; P launches nested background C; an unrelated top-level background S launches next; C fails; P then completes.

Check your answer

C starts immediately because it is nested. S queues because P owns the only slot. C’s failure does not decrement the pool counter—it never incremented it—so S remains queued. When P completes, P releases the slot and queue draining starts S. If your answer started S when C failed, you counted a slot that C never owned.

Now explain, in one sentence each, why these are separate facts: “the Pi session completed,” “the record settled,” and “the parent was notified.”

One strong formulation

Pi completing means the child runtime ended its prompt loop. Record settlement means the manager converted that outcome into in-memory fleet state and updated pool ownership. Parent notification is a later delivery policy that may be suppressed, delayed, or grouped without changing either execution fact.

Next: Chapter 5 keeps the manager record but zooms back into one live Pi session: steering it, bounding it, resuming it, and closing its lifecycle.

Source index

What to retain

  • AgentRecord is the extension's lifecycle view of one managed Pi run.
  • Only top-level background agents occupy the default ten-slot pool.
  • Foreground and nested work bypass that pool for distinct liveness reasons.