From 9f1013506f292fb885eedc015e47652f4ab320c0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 29 Sep 2026 22:30:35 +0200 Subject: [PATCH] docs(durable): specify ownership and subagents (Package 18) --- packages/durable/docs/pico-v5-handoff.md | 12 ++++- packages/durable/docs/pico-v5.md | 65 ++++++++++++++++-------- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/packages/durable/docs/pico-v5-handoff.md b/packages/durable/docs/pico-v5-handoff.md index 8ad37d9a9..4007d4afe 100644 --- a/packages/durable/docs/pico-v5-handoff.md +++ b/packages/durable/docs/pico-v5-handoff.md @@ -11,7 +11,7 @@ facades, membranes, document routing, view projection, events, or clone chains. - Obsolete `pico` and `pico4` prototypes were removed. - `pico3` remains. -- Packages 1–17 are implemented in `packages/durable`; Package 10 was already satisfied by Chord's canonical structural diff implementation. +- Packages 1–18 are implemented in `packages/durable`; Package 10 was already satisfied by Chord's canonical structural diff implementation. ## 1. Records, cursors, and memory tables @@ -583,6 +583,16 @@ waits. Finish invocation-bound owned APIs used by tools and the foreground and background subagent provisioning patterns, including atomic task/conversation/ registry creation and request-ID-safe submission recovery. +An abort cascade withdraws queued inputs in every owned conversation it reaches +(spec §5.4). There is no full-teardown primitive: `close()` stops everything +without outcomes, and a host cancels everything by aborting what `inspect()` +lists. No built-in subagent tool or supervisor task: two concise, product-style +examples, `test/examples/22-subagent-foreground.ts` (child owned by the tool task, +reported through `api.details({ conversationId })`, the UI attaching to the +child's events) and `test/examples/23-subagent-background.ts` (supervisor task, +`app.subagents` name→conversation document, request-ID-safe submission), show +the patterns. + Test deep ownership trees, owner edges after terminal settlement, nested background boundaries, conversation abort/join with surviving passive writes and background tasks, cancellation of waiters without cancellation of work, and diff --git a/packages/durable/docs/pico-v5.md b/packages/durable/docs/pico-v5.md index d4936066d..4e2a5d503 100644 --- a/packages/durable/docs/pico-v5.md +++ b/packages/durable/docs/pico-v5.md @@ -684,10 +684,9 @@ a user message when given, and then resolves; while busy, placement follows section 6 and may occur later. Observe its placement through the conversation watch. An idle wait does not guarantee placement of queued passive writes. -`abortTask()` commits `abortRequested` and the durable foreground-subtree -cascade, then signals and joins the active run; the scheduler then starts the -abort invocation. Marking without signalling is internal: the cascade marks -owned tasks in the same commit. `Conversation.abort()` withdraws queued +`abortTask()` commits `abortRequested`, then signals and joins the active run; +the scheduler then starts the abort invocation and marks the foreground-owned +subtree in its next reconcile commit (section 5.4). `Conversation.abort()` withdraws queued input submissions, marks non-background tasks selected by ordinary ownership traversal, signals them, and resolves only after that scope is ordinarily idle. Passive writes and background subtrees survive. Conversation idle means no live @@ -1685,9 +1684,10 @@ owner edges after the owner becomes terminal, but a background owner is a boundary: ordinary traversal skips that task and its complete owned subtree. Direct conversation operations start inside that conversation regardless of its owner. Directly aborting a live background task includes that task and follows -its ordinary owned subtree; nested background owners remain boundaries. Full -teardown crosses every boundary, marks every live task, and must seal new -admission while it gathers the complete indexed ownership subtree. +its ordinary owned subtree; nested background owners remain boundaries. +`close()` stops every invocation, foreground and background, without writing an +outcome, so the work resumes on reopen. A host that must durably cancel +everything aborts each live task that `inspect()` lists. `Conversation.abort()` withdraws queued inputs and marks live non-background tasks selected by ordinary traversal. `Conversation.waitForIdle()` waits until @@ -1697,8 +1697,20 @@ counting tasks, so ordinary work below a background owner does not block it. Explicit `waitForTask()` waits for its referenced task regardless of the task's background flag. -An abort mark atomically and idempotently cascades to foreground-owned work, -including conversations, tasks, and submissions staged in the same transaction. +An abort mark cascades idempotently to foreground-owned work. The durable +cancellation intent is the owner's own record: its abort mark, or a terminal +outcome other than `completed`. The scheduler derives the rest in a later +reconcile commit, and again at open, so a crash in between loses nothing; work +already queued on the Session line may make one more commit first. Every live +non-background task whose owner chain +reaches a cancelled owner, without first crossing a background owner that is not +itself cancelled, gets an abort mark, including work created there after the +cascade. Deriving the marks in their own commit +keeps them out of the owner's commit, which may already have written the tasks +involved. Every conversation the cascade reaches is treated like +`Conversation.abort()`: its queued input submissions become `unanswered` with +reason `aborted` and leave its inbox, while queued writes stay. The cascade starts +below the cancelled task, so that task's own conversation keeps its queue. Terminal outcomes `failed`, `faulted`, `orphaned`, and `aborted` record the same durable cancellation intent; `completed` does not. Conversation records and owner edges are never retired with the task. Active invocations are signalled @@ -2421,10 +2433,15 @@ the same way; the admitted submission remains durable after those methods reject Invocation-owned document watches stop when the invocation ends. A foreground subagent conversation is explicitly owned by its tool task. One -transaction creates or forks the child and records its durable registry mapping; -after settlement the tool reacquires the child, submits with the registered -request ID, and waits for that submission's result. Aborting or abnormally -terminalizing the tool task cascades through the owned scope. +transaction creates or forks the child; the owner edge is its durable record, so +a replay-safe rerun finds the child with `scanConversations({ ownerTaskId })` +before creating one. After settlement the tool reacquires the child, submits +with a request ID derived from its task ID, which a rerun keeps, and waits for +that submission's result. Provider call IDs are not unique across a Session and +must not serve as request IDs. Aborting or abnormally +terminalizing the tool task cascades through the owned scope. The tool reports +the child in its running details, for example `api.details({ conversationId })`, +so a UI that sees the call can attach to the child's view or events. A background subagent is provisioned in one transaction. After deduplicating by its durable registry key, the tool stages a background supervisor task `B`, a @@ -2433,7 +2450,8 @@ semantic name to `C` plus its stable request ID. `B` may be staged earlier in th same transaction and used immediately as `C`'s owner. Its durable input contains the exact submission draft and the registry location/key; it need not contain `C`'s not-yet-created ID. Once scheduled, `B` resolves `C` from the mapping, -verifies the immutable owner edge, and performs normal `Conversation.submit()`. +which was committed together with `C` and its owner edge, and performs normal +`Conversation.submit()`. A crash before admission makes `B` retry; a crash after admission returns the existing request-ID-deduplicated `Submission`. The initiating tool may race the same submit for lower latency and wait only for the durable admission receipt. @@ -2443,6 +2461,8 @@ its owned scope. Applications may maintain a conversation document mapping semantic subagent names to durable conversation IDs and application-minted submission request IDs. +A UI lists background subagents from that document; a child is active while its +`pi.live.run` is set. Such a live registry uses `fork: "initial"` so children do not inherit the parent's agent list and its update is not a selected fork source. Conversation creation/forking, supervisor creation when applicable, and the registry mapping @@ -2455,8 +2475,7 @@ request to submit; a crash after admission retries the same request and receives the existing `Submission`. A read-only lookup by conversation/request ID can report absence or return the durable queued/placed/done/unanswered receipt. Submission admission therefore needs no private transaction shortcut and does -not move onto `Tx`. Kernel ownership indexes independently drive abort and idle -traversal. +not move onto `Tx`. Owner edges alone drive abort and idle traversal. A tool result may request `addTools`, `terminate`, or `handoff`. Post-tools appends added tool names to the configured loadout; they take effect at the next @@ -2899,14 +2918,18 @@ because the terminal record keeps it; the call is read from the assistant entry. registered policy are `safe`, it executes again with the stored arguments, without `beforeTool`, and settles like `call`. Otherwise it commits an `interrupted` error result from the slot's durable partial output, details, and - diagnostics. + diagnostics, and ends `failed` with `{ entryId }`. - The result commit bounds the content, appends the diagnostics block (section 7.3), appends one `pi.tool-result` entry with `model: [{ role: "toolResult", toolCallId, toolName, content, details, isError, timestamp }]` and `data: { diagnostics }`, marks the slot `done`, and completes with `{ entryId, control }`. An `isError` result still completes. -- A throw from `execute()` becomes a `tool_error` result; once the invocation is - signalled it propagates instead. +- A throw from `execute()` becomes a `tool_error` result and the task ends + `failed` with `{ entryId }`; once the invocation is signalled it propagates + instead. Ending `failed`, like `aborted`, records cancellation intent (section + 5.4), so conversations the call owns, which nothing supervises any more, are + aborted. A returned `isError` result still completes, and its call's owned work + survives. Either way the run continues with the result entry. - The abort handler commits an `aborted` error result from the slot's durable partial output, details, and diagnostics and ends `aborted` with `{ entryId }`. @@ -3596,8 +3619,8 @@ These are contracts, not invitations to add defensive machinery: already counts it, and subtree sums would count it twice. - **Aborting one run task:** `abortTask()` on a run's post-tools task ends the run but leaves its tool tasks running; their results land in a conversation - that may be idle or running another run. Abort the conversation instead - (Package 18); until then, abort the round's tool tasks together with post-tools. + that may be idle or running another run. Use `Conversation.abort()`, which + aborts every task of the conversation's run, tools included. - **Hook memo names:** hooks share the asking task's memo namespace with the task and other hooks. Prefix memo names. - **Long transactions:** an async commit callback holds the Session mutation