pi-async-tasks
Async tasks are launched by a call that returns at once, and each delivers a result to its session when it ends. This package holds the two kinds this repository owns, one module each. Each module’s README is the sole owner of that module’s supported behavior. This page is the sole owner of what the modules share: the async-task service.
| Module | What it owns |
|---|---|
| subagents | Named headless child agents launched from agent definitions, their lifecycle, and their results |
| shell-tasks | Shell tasks, persistent jobs, output, and result delivery |
The manifest loads subagents/index.ts, then shell-tasks/index.ts. Pi gives each entrypoint its own module cache, so the modules share state only through Symbol.for services (ADR 0002).
Async-task service
Section titled “Async-task service”One versioned service lists the session’s async tasks of both kinds, for any package to read. It is published on globalThis under Symbol.for('pi-async-tasks/live:v1'), an object whose list() returns the tasks, read fresh on every call. shared/contracts/async-tasks.ts holds the key, the types, the task message names, and the stateless read helpers; service.ts holds the publishing side. Neither holds state.
Each listed task carries:
| Field | Meaning |
|---|---|
id | The task’s id: a shell task’s b… id or a subagent’s launch id |
kind | shell-task or subagent |
toolCallId | The tool call that launched it, a nested call’s id included; absent for a /bg command launch |
launchedBy | tool-call for the model’s own call, nested-call for a call another tool made, such as a codemode script, command for /bg |
name, agent | Its name; a subagent’s agent definition |
delivers | Its result will be delivered to the session |
triggersRun | That delivery triggers a run; never true without delivers |
persistent | A persistent job, up rather than progressing |
wakeCapable | Derived: it delivers, the delivery triggers a run, and it is not persistent |
status | running; completing once its outcome is known, until its result is in the session; then terminal |
outcome | Once it ends: { kind, reason } |
The states are durable’s: running, completing, then terminal. The outcome follows the object model’s Task table: completed is completed; killed and cancelled are aborted; every other ending is failed. The reason keeps the module’s own status: completed, failed, killed, cancelled, timed-out, or idle.
- A task’s result is in the session once its message is a session entry: a wake’s result when its run places it, a result without a wake when Pi appends it. Each module reads the session’s new entries for its results only when it lists a completing task. A task completes from the moment its outcome is known until then, and a wake’s run sees its task terminal once its own result is placed. A task that delivers no result goes from running to terminal.
- A shell task runs until it publishes its terminal snapshot, so a task whose output is still closing runs. It completes from then until its result is in the session. Its delivery flags are
notifyOnCompletionandtriggerOnCompletion. - A subagent runs while it is in the live set, from its launch until its outcome is known and result publication is attempted. It leaves that set after that attempt and completes until the result is in the session. A Goal-owned child whose Goal session is no longer current gets no result, so it stays completing, and user-turns and herdr-report keep holding on it, as they did when they waited for its result message. Its result is always delivered and wakes the session; a Goal-owned child’s result wakes through Goal’s guarded wake rather than the message’s own trigger, and the service still lists it as waking, as every reader did before the service.
- The list keeps ended tasks with their outcome: a shell task as long as its module keeps its record (the latest 100 records), and the latest 100 ended subagents. Neither cap drops a task that is still completing; it stays until its result lands. No cap applies to a result that never lands, such as a withheld Goal result, a drop the redelivery rule never sees, or a resend that is itself lost, so such a record lasts as long as its module’s runtime. That growth is bounded by the results lost, and the registries are rebuilt on session replacement and
/reload. Within each kind the order is launch order. - The service belongs to the session. Each module publishes its own source at
session_startand removes it atsession_shutdown. Shell tasks list nothing once their session’s shutdown starts; subagents stay listed while shutdown cancels them, until their children have delivered their results. A replaced session’s source lists nothing, and the service goes when neither module publishes. An absent or failing service lists nothing. - A module learns that a call was nested from Pi’s
tool_execution_start, which carriesparentToolCallIdand comes before the call runs. - Each module wires its side through one helper,
installAsyncTaskSource(pi, kind, toolName)inservice.ts: it tracks nested launches of the module’s tool, watches the session for native results, binds a session-scoped Relay sender, and publishes or removes the module’s list when the module says. Relay alone owns delivery and replay. A reader drops any listed record with a missing or ill-typed field it uses.
readAsyncTasks() is the reader. Look it up on every use, never at load or in a session_start handler: user-turns and herdr-report load before this package, so their session_start runs before the new session’s service exists. They read it when a tool call ends and when the session settles, by which time it is published. This is the per-call lookup the work-status store asks of its producers, whose owner loads first.
A reader that needs a task’s origin attributes it to a user turn by the rule user-turns uses: a user turn’s launches are the tasks a tool call launched, launchedBy tool-call or nested-call, that the reader first sees listed while that user turn is open. The reader keeps the ids it has seen for the whole session and reads the service at least at every tool end, so a task is first seen at the end of the call that launched it. A task first seen while no user turn is open, or while the reader is off, belongs to none, and a /bg launch belongs to none. The rule reads no tool-call id: an empty id, which pi-ai’s openai-completions adapter sends when a provider gives none, and an id a provider reuses cannot misattribute a task. toolCallId stays on each task for readers that need the call itself.
asyncTaskResult(customType, details) tells which task a delivered custom message is the result of, so a reader that needs a result’s content, such as user-turns’ answer continuation, never matches result types itself. A subagent_ping is no result.
Readers today:
- user-turns takes as a user turn’s launches the tool-launched tasks it first sees listed while that user turn is open and waits on its wake-capable ones. tui-display draws that wait from user-turns’ service.
- herdr-report counts the tasks the model launched with its own tool call whose result will be delivered.
- A subagent child’s settle report waits while its user-turn domain is open; only without that publisher does it count running wake-capable tasks directly.
- The worktree remove gate refuses while a subagent runs.
Which states hold a reader is each reader’s own rule:
| Reader | Counts as pending | Lets a task go |
|---|---|---|
| user-turns’ wait and settle decision | running and completing | when its result is in the session, the result message it waited for before |
| herdr-report’s pending count | running and completing | when its result is in the session, as the native rules’ result message |
| the settle report, only without a user-turn domain publisher | running only | as its result is sent: a shell task just before, a subagent just after |
| the worktree remove gate | running subagents only | as its result is sent, when the live count it read before dropped it |
A result that is never placed, such as a withheld Goal result, holds the user turn and the pane. A child with that open domain reports waiting; only the domain-absent fallback lets completing work go. An unreadable domain also holds completion, never selects that fallback.
The running-subagent count, pi-subagents/running-subagent-count, is no longer published: the remove gate was its last reader.
Readers kept off the service: session cost in tui-footer finds child session files in launch and result messages, because it counts children from the whole session history, which outlives the process; and Herdr’s own integration, which Herdr manages.
Result redelivery
Section titled “Result redelivery”Owned Relay owns the single send/claim/placement/ACK/recovery path for both peer mail and these native task messages. This package retains launch facts, native payloads, result readers, process shutdown and task publication, not a second replay queue.
Pi can clear a busy run’s message queues on Esc, TUI abort, tree navigation or RPC clear_queue; RPC abort alone keeps queued messages. The shared receiver infers a proven drop from actual placement and sufficient later empty-queue evidence. It resends once with the same transport ID: after abort as non-waking nextTurn, otherwise with the original allowed intent. Polling cannot authorize another retry. A second lost result remains completing/uncertain.
A held next-turn message survives same-session reload without a duplicate submission. Replacement and fresh processes cannot inject old task-incarnation traffic into a new lifetime. Exact active-branch placement can finish an ACK without reinjection; sibling-only proof cannot. Goal-owned context is admitted by its current owner at the receiver, and its guarded wake is notified only after actual placement. Temporary withholding is distinct from stale rejection.
Notifying launches require a session-bound sender before process side effects; delivery-off shell tasks do not. A later publication failure is visible through existing error reporting and Relay diagnostics, leaves the ended task completing until placement, and does not rerun work. The source binds the sender to the original scope, including terminal callbacks while subagent shutdown drains.
Evidence
Section titled “Evidence”Service tests drive both modules’ registered tools and /bg with fake processes: the fields through each kind’s lifecycle, both actual results held before placement while the domain, display, pane and automatic child report wait, then each placement releasing its wait; wake capability across delivery, trigger, and persistence; how each task was launched; the outcome mapping; the running subagents the worktree gate counts; the shutdown order; and the session scope. Domain report tests retain the absent-domain running-work fallback and unreadable-domain completion hold. Redelivery host tests drop queued results in a real Pi session with a faux model, the way Esc, /tree, and RPC clear_queue do, and check that each result lands once:
- After an Esc, a shell task’s follow-up starts no run. User-turns and herdr-report wait until another task’s wake starts the next run with it; then user-turns writes its answer summary and herdr-report counts nothing.
- After
/tree’s clear and abort, the abort resolves without a model call, and the result lands on the continued branch. - A result an RPC
abortkeeps lands once from the queue. A resultclear_queuedrops starts its own run. The source stops listening for turn ends once nothing waits. - A result
clear_queuedrops lands once even when an earlier extension’s settle handler starts a run of its own before the resend’s. - A result steered on the
message_endof a note Pi flushes after it decides to settle is not taken for dropped, and lands once from the queue. - Peer mailbox ACK waits for native placement, held results survive actual reload once, shutdown permits old-scope terminal publication, and storage failure leaves ended work completing without rerunning it.
Goal notification host tests cover delayed stale-owner rejection, temporary withholding until owner reconsideration, and context placement before Goal wakes.
The registry tests keep a completing record past each cap. Each reader’s tests feed it real producers through the service; the user-turns nested-host and herdr-report host tests load the reader before this package in a real Pi.
Each module keeps its CHANGELOG.md from before the merge as history; this package’s changelog continues from there.