From 95aac7b8598cfa0b4eba8d3ffbe967befbb5726f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:13:53 +0000 Subject: [PATCH 1/3] feat(lineage): expose the live agent tree (siblings, children, roots) on request.lineage (#457) --- .changeset/457-lineage-tree.md | 5 + docs/entry-conventions.md | 33 +++ examples/host-test/src/dump.ts | 12 +- examples/worktree-proximity/README.md | 129 ++++++----- .../worktree-proximity/src/coordination.ts | 18 +- .../src/domain/proximity.ts | 43 ++-- .../worktree-proximity/src/event-support.ts | 109 +++++---- .../src/events/agent/start.tsx | 34 +-- .../src/events/agent/stop.tsx | 58 +++++ .../src/events/session/start.tsx | 27 +-- .../worktree-proximity/src/events/stop.tsx | 16 +- .../src/events/tool/after.tsx | 12 +- .../src/events/tool/before.tsx | 22 +- .../src/mcp/coordinator/tools/status.tsx | 106 +++++++-- .../src/providers/agent-topology.ts | 18 +- examples/worktree-proximity/src/state.ts | 111 ++++------ .../tests/proximity.test.ts | 60 +++-- .../tests/route-unit/routes.test.ts | 161 +++++++++++--- .../tests/worktree-proximity-journeys.test.ts | 206 +++++++++++++----- packages/rsc-runtime/src/agent-request.ts | 50 +++++ packages/rsc-runtime/src/lineage/registry.ts | 93 ++++++-- packages/rsc-runtime/src/plugin.ts | 2 + .../tests/lineage-codex-rollout.test.ts | 4 + .../tests/lineage-registry.test.ts | 188 ++++++++++++++++ website/docs/en/guide/authoring/mcp.mdx | 21 ++ website/docs/zh/guide/authoring/mcp.mdx | 18 ++ 26 files changed, 1159 insertions(+), 397 deletions(-) create mode 100644 .changeset/457-lineage-tree.md create mode 100644 examples/worktree-proximity/src/events/agent/stop.tsx diff --git a/.changeset/457-lineage-tree.md b/.changeset/457-lineage-tree.md new file mode 100644 index 000000000..5f1447203 --- /dev/null +++ b/.changeset/457-lineage-tree.md @@ -0,0 +1,5 @@ +--- +"@agent-bundle/runtime": patch +--- + +Expose the live agent tree to routes: `(await agent()).lineage.value.tree` — `{ siblings, children, roots }` of `AgentLineagePeer` (`{ conversation, depth, parent?, startedAt, subagent?, resolution }`) — lists every other live conversation under the request's root (any depth, the root itself included for a subagent), the request's live children, and the other live root conversations (on Cursor, only those seen in the same `workspace_roots`), read at resolve time from the same lineage registry that placed the request on every surface it feeds (event routes, generated MCP tool calls correlated through a hook window or a Codex `_meta`). Nothing is invented: stopped conversations are not listed, each peer carries the registry's own `resolution` for its placement, and the tree is absent when the registry did not place the request (a standalone hook, or a `_meta` naming a thread the registry never saw start). New exported types `AgentLineageTree` and `AgentLineagePeer`; `AgentLineage.tree` is optional, so existing readers and injected `context.lineage` fixtures are unchanged. (#457) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index e5afdf1ed..b17cf77aa 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -448,9 +448,42 @@ interface AgentLineage { generation?: string; // Cursor generation_id, Codex turn_id, Claude prompt_id subagent?: { id: string; type?: string; toolCallId?: string; isParallelWorker?: boolean }; resolution: 'native' | 'registry' | 'confirmed' | 'transcript' | 'inferred'; + tree?: AgentLineageTree; // who else is alive, when the registry placed this request +} + +interface AgentLineageTree { + siblings: readonly AgentLineagePeer[]; // every other live conversation under the same root, any depth + children: readonly AgentLineagePeer[]; // live conversations whose parent is this one + roots: readonly AgentLineagePeer[]; // other live depth-0 conversations (Cursor: same workspace_roots) +} + +interface AgentLineagePeer { + conversation: string; + depth: number; + parent?: string; + startedAt: string; // when the registry saw it start + subagent?: AgentLineageSubagent; + resolution: AgentLineageResolution; // the trust level of *that* node's placement } ``` +`tree` is the other half of "where am I": the live conversations around this +one, read from the same registry that placed the request (#457). It lists +only what the registry holds — no node is invented, and a stopped node is not +listed — scoped to what the conversation may see: everything alive under its +own root (`siblings`, oldest first, the root itself included for a subagent, +so a coordinator sees the whole live tree it belongs to; filter by `parent` +for same-parent siblings), its direct `children`, and the other live `roots` +(on Cursor only roots seen in the same `workspace_roots`, the rule that scopes +child binding; a Cursor child whose conversation has not spoken yet is listed +under its `subagent_id`). Each peer carries the registry's own `resolution` +for its placement, judged exactly as on a request that node itself made. The +tree is absent when the registry did not place the request: a standalone +hook, or a Codex `_meta` that names a thread the registry never saw start, +still answers "who am I" but not "who else is here". It travels as plain +frozen data, so the Flight worker receives it unchanged and route-unit tests +inject it through the same `context.lineage` seam. + `resolution` is the trust level of `parent`/`root`/`depth`: `native` — the host named them on this payload (a Claude/Codex root, a Codex tool call's `_meta`); `registry` — the warm runtime's registry placed the conversation diff --git a/examples/host-test/src/dump.ts b/examples/host-test/src/dump.ts index 4086a02f3..5a1fd5761 100644 --- a/examples/host-test/src/dump.ts +++ b/examples/host-test/src/dump.ts @@ -50,12 +50,20 @@ const asCaptures = (records: readonly Record[]): CaptureRecor .filter((record) => typeof record['kind'] === 'string' && typeof record['recordedAt'] === 'string') .map((record) => record as unknown as CaptureRecord); +/** + * A record belongs to a conversation when the host named it in an id field, + * as the session, or anywhere on the request's *own* lineage chain. The + * `tree` the lineage carries lists other live conversations (#457) and is + * deliberately not searched: a sibling's records are not this conversation's. + */ const matchesConversation = (record: CaptureRecord, conversation: string): boolean => { if (Object.values(record.ids).some((value) => value === conversation)) return true; const session = (record.request as { session?: { value?: { sessionId?: string } } }).session; if (session?.value?.sessionId === conversation) return true; - const lineage = JSON.stringify((record.request as { lineage?: unknown }).lineage ?? null); - return lineage.includes(JSON.stringify(conversation)); + const lineage = (record.request as { lineage?: Observed | { readonly state?: undefined } }).lineage; + if (lineage?.state !== 'available') return false; + const { conversation: own, parent, root, subagent } = lineage.value; + return own === conversation || parent === conversation || root === conversation || subagent?.id === conversation; }; /** The compact shape a human or an agent scans first; `full` returns the whole line. */ diff --git a/examples/worktree-proximity/README.md b/examples/worktree-proximity/README.md index 6d13b1032..046da280c 100644 --- a/examples/worktree-proximity/README.md +++ b/examples/worktree-proximity/README.md @@ -1,27 +1,33 @@ # Worktree proximity This advanced composition reference coordinates one root task and two child -agents working in linked worktrees of the same Git repository. Application -code records topology and current intent, detects path or dependency overlap, -warns the actor handling the current event, and publishes a durable notice -addressed to the other actor. The notice ledger attempts delivery on that -actor's next admitted event. No daemon and no native directed-message API are -required. +agents working in linked worktrees of the same Git repository. The runtime's +lineage registry supplies the agent tree — who the root is, which children +are alive, who is a sibling — through `(await agent()).lineage`; application +code records only which worktree each agent works in and its current intent, +detects path or dependency overlap, warns the actor handling the current +event, and publishes a durable notice addressed to the other actor. The +notice ledger attempts delivery on that actor's next admitted event. No +daemon and no native directed-message API are required. This example is intentionally not part of the newcomer path. ## Scenario -1. A `session/start` event records the root actor. -2. Two `agent/start` events bind native child actor IDs to distinct worktrees. +1. A `session/start` event binds the root conversation to its worktree. +2. Two `agent/start` events bind the child conversations the runtime placed + under that root to distinct worktrees. 3. `tool/before` records current path and dependency intent. -4. The pure proximity domain compares active intents from different worktrees. +4. The pure proximity domain compares active intents from different + worktrees, ignoring an intent whose agent `request.lineage.tree` no longer + lists as alive. 5. A conflict renders an `Agent.Context` warning with an `outcome: continue` result and publishes a recipient-scoped notice. 6. The other actor's next event admits the pending notice, changes its evidence-backed state to `attempted`, and renders its content as context. -7. `tool/after` records an empty current intent, and `stop` marks the actor - stopped. +7. `tool/after` records an empty current intent; `agent/stop` and `stop` + release the actor's binding and whatever intent it still held (the + registry records the stop itself, so siblings stop seeing it). The demonstration dependency convention is a `deps:` string in tool input: @@ -37,33 +43,43 @@ repository-relative slash-separated paths by the domain module. The application has four planes: +- **Lineage** — the agent tree is the runtime's. `(await agent()).lineage` + answers who this request is (`conversation`, `parent`, `root`, `depth`, + `resolution`) and, through `lineage.value.tree`, who else is alive: + `siblings` (every other live conversation under the same root, the root + included), `children`, and other live `roots`, each with the registry's own + `resolution` for its placement. `agentTree()` in `src/event-support.ts` + turns that into the coordinator's report; `liveConversations()` turns it + into the liveness the domain uses. - **Providers** — `git-worktree` derives repository, branch, commit, common Git directory, and linked-worktree identity without throwing for expected degradation. `agent-topology` reports that its snapshot is unavailable - because providers receive no request identity or state handle. -- **Events** — canonical shared-runtime routes observe actors, bind worktrees, - record or clear intent, detect conflicts, render current-actor context, and - publish or admit notices. -- **State and notices** — one workspace-durable topology definition and the - framework notice definition share the generated runtime's SQLite driver. - Routes use only the mounted `(await agent()).state` and - `(await agent()).notices` handles; SQLite supplies cross-process durability - and idempotency without a daemon. + because providers receive no request lineage. +- **Events** — canonical shared-runtime routes bind actors to worktrees, + record or clear intent, detect conflicts, render current-actor context, + release stopped actors, and publish or admit notices. +- **State and notices** — one workspace-durable intent definition (worktree + bindings, activities, refusals) and the framework notice definition share + the generated runtime's SQLite driver. Routes use only the mounted + `(await agent()).state` and `(await agent()).notices` handles; SQLite + supplies cross-process durability and idempotency without a daemon. - **Domain** — `src/domain/proximity.ts` contains all collision decisions and performs no I/O. The generated runtime owns the durable root. It mounts SQLite at `$AGENT_BUNDLE_PLUGIN_ROOT/state`, with the generated artifact root as the -fallback anchor, and mounts topology state and the notice ledger over that -same driver. The application never opens a second store from Git identity -data; `gitWorktree.commonDir` remains identity evidence only. - -The issue sketch places a snapshot at `providers.agentTopology.snapshot`, but -a provider factory receives only `{ invocation, signal }` — no request -identity, no `lineage`, and no mounted `state` handle -([agent-bundle#459](https://github.com/scriptedalchemy/agent-bundle/issues/459)) — -so this provider reports an honest unavailable result and routes read -snapshots from `(await agent()).state.read()` instead. +fallback anchor, and mounts intent state, the notice ledger, and its own +lineage journal over that same driver. The application never opens a second +store from Git identity data; `gitWorktree.commonDir` remains identity +evidence only. + +The issue sketch places the agent tree at `providers.agentTopology`. The tree +is on the request (`request.lineage.tree`, +[#457](https://github.com/scriptedalchemy/agent-bundle/issues/457)), but a +provider factory receives only `{ invocation, signal }` — not the request's +`lineage` ([#459](https://github.com/scriptedalchemy/agent-bundle/issues/459)) — +so this provider reports an honest unavailable result and routes read the +tree from `(await agent()).lineage` instead. `worktree()` in `src/api.ts` is the issue-mandated custom Promise API over the provider value. `useWorktree()` is the hook-shaped variant for Server @@ -80,34 +96,41 @@ registry's own resolutions (`confirmed` once the host has named every edge up to the root; `transcript` is read from the host's own rollout file), and `derived` is this application's fallback: -- `session/start` observes `session:` as the root actor, where the root - is `(await agent()).lineage.root` when the runtime resolved a lineage and the - native `session_id` otherwise. -- `agent/start` records the child and its parent from `request.lineage` - (`subagent.id`, `parent`, `resolution`) when the runtime placed the start - below the root — which needs the spawning `Agent`/`Task` `tool/before` to - have passed through the same shared runtime — and from the native `agent_id` - + `session_id` pair otherwise. +- `session/start` binds the root conversation — `(await agent()).lineage.root` + when the runtime resolved a lineage and the native `session_id` otherwise — + to its worktree, under the same id `request.lineage.tree` lists it by. +- `agent/start` binds the child named by `request.lineage` (`subagent.id`, + with `resolution` as the binding's provenance) when the runtime placed the + start below the root — which needs the spawning `Agent`/`Task` + `tool/before` to have passed through the same shared runtime — and the + native `agent_id` otherwise. The edge itself (parent, depth, root) is not + recorded: the registry holds it. - Claude and Codex put the subagent's `agent_id` on every one of its hook payloads; Cursor gives the child a fresh `conversation_id` that only the runtime registry can bind to its `subagentStart`. A tool or stop event therefore resolves its actor in order of evidence: the child named by `request.lineage` (depth above zero), then the native `agent_id`, then the - active actor already bound to the event worktree, and finally the explicit - derived identity `worktree:`. A carried child the topology has not - seen is observed and bound with the provenance the evidence carried; a - derived identity is never upgraded. A root-level tool envelope (lineage + actor most recently bound to the event worktree, and finally the explicit + derived identity `worktree:`. A carried child not yet bound is bound + with the provenance the evidence carried; a derived identity is never + upgraded. A root-level tool envelope (lineage depth zero) resolves through the worktree binding, so intent stays attributed per worktree. - `agent/start` without either lineage or native identity records an `edgeRefused` event and renders that parent identity is unavailable. It refuses to fabricate a topology edge. - -`request.lineage` covers the request's own parent, root, depth, and subagent -record. Siblings, children, and other roots are not exposed -([#457](https://github.com/scriptedalchemy/agent-bundle/issues/457)), so this -application keeps its own topology state for the whole-tree view the -coordinator status reports. +- `agent/stop` and `stop` release the actor the envelope names (binding and + intent); an identity-less stop releases nobody. + +Liveness is the registry's word, not this application's: when +`request.lineage.tree` is present, an intent held by a host-identified actor +the tree no longer lists under our root is stale and warns nobody; a derived +`worktree:` actor is not a conversation and is never filtered that +way; and a lineage with no tree (a payload that proved only its own chain, a +standalone hook, or none at all) presumes nothing about who stopped. The +coordinator `status` tool reports the tree the runtime resolved for *its* +call — a client no pre-tool hook window names gets an honest +`agents: unavailable`, never a tree from another caller's point of view. Unsupported worktree, actor, parent, state, and delivery conditions are rendered as unavailable instead of being replaced with invented evidence. @@ -115,7 +138,7 @@ rendered as unavailable instead of being replaced with invented evidence. ## Framework primitive wiring Generated route workers mount the extracted `src/state.ts` definition and the -notice ledger into every request scope. `withTopology` and `withNotices` are +notice ledger into every request scope. `withIntent` and `withNotices` are small capability adapters over those real handles. If a surface has no mounted handle, they return an unavailable result and the route renders that reason as `Agent.Context`; there is no fallback write path. @@ -131,8 +154,8 @@ keys continue to name the target actor. Lineage-addressed delivery `(await agent()).notices.read()` exposes only deliveries attempted for the current invocation; publisher-scoped visibility is [#460](https://github.com/scriptedalchemy/agent-bundle/issues/460). The -coordinator status therefore reports topology facts only and does not claim a -whole-ledger pending count. +coordinator status therefore reports the agent tree, bindings, and intents +only and does not claim a whole-ledger pending count. ## Evidence boundary @@ -143,7 +166,9 @@ owner. The root integration-pool suite `packages/agent-bundle/tests/worktree-proximity-journeys.test.ts` builds the real artifact, invokes generated hooks as separate processes against linked Git worktrees, and proves warning, workspace-directed delivery, replay -idempotency, and exact-revision restart durability through the generated MCP +idempotency, exact-revision restart durability, and the registry-fed agent +tree — spawned children visible to the root's `status` call, still visible +after a server restart, gone after `agent/stop` — through the generated MCP server. Journey 8 has two honesty layers: the generated wrapper fails closed on an identity-less `SubagentStart` for host contracts that require `agent_id`, while the route-unit suite proves the route records a refusal for diff --git a/examples/worktree-proximity/src/coordination.ts b/examples/worktree-proximity/src/coordination.ts index 4ae8d4d57..961714fe7 100644 --- a/examples/worktree-proximity/src/coordination.ts +++ b/examples/worktree-proximity/src/coordination.ts @@ -5,12 +5,12 @@ import { import type { AgentNoticesHandle } from '@agent-bundle/runtime/notices'; import { - type TopologyEvents, - type TopologyState, + type IntentEvents, + type IntentState, } from './state.js'; -export type TopologyAccess = - Pick, 'dispatch' | 'read'>; +export type IntentAccess = + Pick, 'dispatch' | 'read'>; export type CapabilityResult = | { @@ -22,13 +22,13 @@ export type CapabilityResult = readonly state: 'unavailable'; }; -export const withTopology = async ( - operation: (topology: TopologyAccess) => Promise, +export const withIntent = async ( + operation: (intent: IntentAccess) => Promise, ): Promise> => { const context = await agent(); if (context.state === undefined) { return { - reason: 'Topology state unavailable: this request has no mounted state handle.', + reason: 'Intent state unavailable: this request has no mounted state handle.', state: 'unavailable', }; } @@ -36,13 +36,13 @@ export const withTopology = async ( return { state: 'available', value: await operation( - context.state as AgentStateHandle, + context.state as AgentStateHandle, ), }; } catch (error) { return { reason: - `Topology state unavailable: ${error instanceof Error ? error.message : String(error)}`, + `Intent state unavailable: ${error instanceof Error ? error.message : String(error)}`, state: 'unavailable', }; } diff --git a/examples/worktree-proximity/src/domain/proximity.ts b/examples/worktree-proximity/src/domain/proximity.ts index e5006bb18..de460873b 100644 --- a/examples/worktree-proximity/src/domain/proximity.ts +++ b/examples/worktree-proximity/src/domain/proximity.ts @@ -1,4 +1,4 @@ -import type { TopologyState } from '../state.js'; +import type { IntentState } from '../state.js'; export interface ProximityIntent { readonly actorId: string; @@ -12,6 +12,18 @@ export interface ProximityConflict { readonly worktreeRoot: string; } +export interface ProximityOptions { + /** + * The conversations the runtime's lineage registry lists as alive around + * the current request (itself and `lineage.tree.siblings`). When given, an + * intent held by a host-identified actor outside that set is stale — its + * agent stopped — and is ignored; derived `worktree:` actors are not + * conversations and are never filtered. When absent, every recorded intent + * counts: an unknown tree is not an empty one. + */ + readonly liveConversations?: ReadonlySet; +} + const normalizeSegments = (value: string): string => { const segments: string[] = []; for (const segment of value.replaceAll('\\', '/').split('/')) { @@ -39,38 +51,39 @@ const normalizePath = (value: string, worktreeRoot: string): string => { const normalizeDependency = (value: string): string => value.trim().toLowerCase(); export const findProximity = ( - snapshot: TopologyState, + snapshot: IntentState, currentWorktree: string, intent: ProximityIntent, + options: ProximityOptions = {}, ): readonly ProximityConflict[] => { const currentPaths = new Set(intent.paths.map((path) => normalizePath(path, currentWorktree)).filter(Boolean)); const currentDependencies = new Set( intent.dependencies.map(normalizeDependency).filter((dependency) => dependency !== ''), ); - const actors = new Map(snapshot.actors.map((actor) => [actor.id, actor])); + const bindings = new Map(snapshot.bindings.map((binding) => [binding.actorId, binding])); const conflicts: ProximityConflict[] = []; for (const activity of snapshot.activities) { if (activity.actorId === intent.actorId) continue; - const actor = actors.get(activity.actorId); + const binding = bindings.get(activity.actorId); + if (binding === undefined || binding.worktreeRoot === currentWorktree) continue; if ( - actor === undefined - || actor.status !== 'active' - || actor.worktreeRoot === undefined - || actor.worktreeRoot === currentWorktree + options.liveConversations !== undefined + && binding.provenance.actorId !== 'derived' + && !options.liveConversations.has(binding.actorId) ) { continue; } const sharedPath = activity.paths - .map((path) => normalizePath(path, actor.worktreeRoot!)) + .map((path) => normalizePath(path, binding.worktreeRoot)) .find((path) => currentPaths.has(path)); if (sharedPath !== undefined) { conflicts.push({ - actorId: actor.id, + actorId: binding.actorId, summary: - `Worktrees ${currentWorktree} and ${actor.worktreeRoot} both intend to change path ${sharedPath}.`, - worktreeRoot: actor.worktreeRoot, + `Worktrees ${currentWorktree} and ${binding.worktreeRoot} both intend to change path ${sharedPath}.`, + worktreeRoot: binding.worktreeRoot, }); } @@ -79,10 +92,10 @@ export const findProximity = ( .find((dependency) => currentDependencies.has(dependency)); if (sharedDependency !== undefined) { conflicts.push({ - actorId: actor.id, + actorId: binding.actorId, summary: - `Worktrees ${currentWorktree} and ${actor.worktreeRoot} both depend on ${sharedDependency}.`, - worktreeRoot: actor.worktreeRoot, + `Worktrees ${currentWorktree} and ${binding.worktreeRoot} both depend on ${sharedDependency}.`, + worktreeRoot: binding.worktreeRoot, }); } } diff --git a/examples/worktree-proximity/src/event-support.ts b/examples/worktree-proximity/src/event-support.ts index 6f203995e..5abab2297 100644 --- a/examples/worktree-proximity/src/event-support.ts +++ b/examples/worktree-proximity/src/event-support.ts @@ -2,13 +2,14 @@ import { agent, type AgentDocumentNode, type AgentLineage, + type AgentLineageTree, type AgentNoticeDelivery, type Observed, } from '@agent-bundle/runtime'; import type { AvailableWorktree } from './api.js'; -import type { TopologyAccess } from './coordination.js'; -import type { IdentityProvenance, TopologyState } from './state.js'; +import type { IntentAccess } from './coordination.js'; +import type { IdentityProvenance, IntentState } from './state.js'; export interface EventIdentity { readonly idempotencyKey: string; @@ -33,6 +34,55 @@ export interface CarriedChild extends ResolvedActor { /** The conversation lineage the runtime resolved for the current request. */ export const requestLineage = async (): Promise> => (await agent()).lineage; +/** + * The live conversations this request may treat as present: itself and every + * other live node under its root, as the runtime's lineage registry holds + * them (`request.lineage.tree.siblings`, #457). `undefined` when the runtime + * resolved no tree — a lineage a payload proved on its own, or none at all — + * so callers fall back to their own evidence instead of treating an unknown + * tree as an empty one. + */ +export const liveConversations = (lineage: Observed): ReadonlySet | undefined => { + if (lineage.state !== 'available' || lineage.value.tree === undefined) return undefined; + return new Set([lineage.value.conversation, ...lineage.value.tree.siblings.map((peer) => peer.conversation)]); +}; + +/** The agent tree around a request: its own chain plus the live peers the registry lists, or why there is none. */ +export type AgentTreeView = + | ({ + readonly state: 'available'; + } & Pick & AgentLineageTree) + | { readonly reason: string; readonly state: 'unavailable' }; + +/** + * The whole-tree view the coordinator reports, read from `request.lineage` + * and nothing else. A lineage with no `tree` (a payload that proved only its + * own chain, or a standalone hook) is reported as unavailable rather than as + * an empty tree. + */ +export const agentTreeOf = (lineage: Observed): AgentTreeView => { + if (lineage.state !== 'available') { + return { reason: `lineage unavailable (${lineage.reason})`, state: 'unavailable' }; + } + const { conversation, depth, parent, resolution, root, tree } = lineage.value; + if (tree === undefined) { + return { reason: `lineage resolved ${resolution} without the registry tree`, state: 'unavailable' }; + } + return { + children: tree.children, + conversation, + depth, + ...(parent === undefined ? {} : { parent }), + resolution, + root, + roots: tree.roots, + siblings: tree.siblings, + state: 'available', + }; +}; + +export const agentTree = async (): Promise => agentTreeOf(await requestLineage()); + /** * The subagent a request speaks for, when the runtime's `request.lineage` * places it below the root. The runtime resolves the same shape on every @@ -114,50 +164,41 @@ export const extractIntent = ( /** * The actor a tool or stop envelope belongs to, in order of evidence: the * child the envelope itself names (runtime lineage, then native `agent_id`), - * the active actor already bound to the event worktree, and finally the - * explicit derived identity `worktree:`. A carried child the topology - * has not seen yet (its `agent/start` was missed) is observed and bound with - * the provenance the evidence carried; a derived actor is never upgraded. + * the actor already bound to the event worktree, and finally the explicit + * derived identity `worktree:`. A carried child not yet bound (its + * `agent/start` was missed) is bound with the provenance the evidence + * carried; a derived actor is never upgraded. Whether an actor is *alive* is + * not recorded here: that is the runtime lineage registry's answer + * (`request.lineage.tree`), and a stop releases the binding outright. */ export const actorForWorktree = async ( - topology: TopologyAccess, + intent: IntentAccess, worktree: AvailableWorktree, canonical: EventIdentity, native: Readonly> = {}, -): Promise<{ readonly actor: ResolvedActor; readonly snapshot: TopologyState }> => { - const before = await topology.read(); +): Promise<{ readonly actor: ResolvedActor; readonly snapshot: IntentState }> => { + const before = await intent.read(); const carried = await carriedChild(native); if (carried !== undefined) { - const known = before.state.actors.find((actor) => actor.id === carried.id); + const known = before.state.bindings.find((binding) => binding.actorId === carried.id); if (known !== undefined) { - return { actor: { id: known.id, source: known.provenance.id }, snapshot: before.state }; + return { actor: { id: known.actorId, source: known.provenance.actorId }, snapshot: before.state }; } - await topology.dispatch('actorObserved', { - id: carried.id, - kind: 'child', - parentSessionId: carried.parentSessionId, - provenance: { id: carried.source, parentSessionId: carried.source }, - status: 'active', - }, { - idempotencyKey: `${canonical.idempotencyKey}:carried-actor`, - }); - const boundResult = await topology.dispatch('actorBound', { + const boundResult = await intent.dispatch('actorBound', { actorId: carried.id, - provenance: 'native', + provenance: { actorId: carried.source, worktreeRoot: 'native' }, worktreeRoot: worktree.root, }, { idempotencyKey: `${canonical.idempotencyKey}:carried-worktree`, }); return { actor: { id: carried.id, source: carried.source }, snapshot: boundResult.state }; } - const bound = before.state.actors.find( - (actor) => actor.status === 'active' && actor.worktreeRoot === worktree.root && actor.kind === 'child', - ) ?? before.state.actors.find( - (actor) => actor.status === 'active' && actor.worktreeRoot === worktree.root, - ); + // The actor most recently bound to this worktree, so a root-level envelope + // in a linked worktree stays attributed to the agent working there. + const bound = before.state.bindings.findLast((binding) => binding.worktreeRoot === worktree.root); if (bound !== undefined) { return { - actor: { id: bound.id, source: bound.provenance.id }, + actor: { id: bound.actorId, source: bound.provenance.actorId }, snapshot: before.state, }; } @@ -166,17 +207,9 @@ export const actorForWorktree = async ( id: `worktree:${worktree.root}`, source: 'derived', }; - await topology.dispatch('actorObserved', { - id: actor.id, - kind: 'child', - provenance: { id: 'derived' }, - status: 'active', - }, { - idempotencyKey: `${canonical.idempotencyKey}:derived-actor`, - }); - const boundResult = await topology.dispatch('actorBound', { + const boundResult = await intent.dispatch('actorBound', { actorId: actor.id, - provenance: 'derived', + provenance: { actorId: 'derived', worktreeRoot: 'derived' }, worktreeRoot: worktree.root, }, { idempotencyKey: `${canonical.idempotencyKey}:derived-worktree`, diff --git a/examples/worktree-proximity/src/events/agent/start.tsx b/examples/worktree-proximity/src/events/agent/start.tsx index d0177b616..8193fe0d6 100644 --- a/examples/worktree-proximity/src/events/agent/start.tsx +++ b/examples/worktree-proximity/src/events/agent/start.tsx @@ -3,7 +3,7 @@ import type { AgentEventRouteProps } from 'agent-bundle'; import React from 'react'; import { worktree } from '../../api.js'; -import { withNotices, withTopology } from '../../coordination.js'; +import { withIntent, withNotices } from '../../coordination.js'; import { carriedChild, deliveryContexts, nativeString } from '../../event-support.js'; export const config = { @@ -26,15 +26,17 @@ export default async function AgentStart({ // The runtime's `request.lineage` names the child and its parent when the // registry resolved this start; the native `agent_id` + root `session_id` - // pair is the fallback. Neither present is a refusal, never a guess. + // pair is the fallback. Neither present is a refusal, never a guess. The + // edge itself (parent, depth, root) is the registry's to keep: this route + // records only which worktree the child works in. const child = await carriedChild(native); if (child === undefined) { const sessionId = nativeString(native, 'session_id'); const refusal = nativeString(native, 'agent_id') === undefined ? 'agent/start omitted native agent_id; refused to fabricate a topology edge' : 'agent/start omitted native session_id; refused to fabricate a topology edge'; - const topologyResult = await withTopology(async (topology) => { - await topology.dispatch('edgeRefused', { + const intentResult = await withIntent(async (intent) => { + await intent.dispatch('edgeRefused', { idempotencyKey: canonical.idempotencyKey, observedAt: canonical.observedAt, reason: refusal, @@ -45,7 +47,7 @@ export default async function AgentStart({ }); const noticeResult = await withNotices(async (notices) => notices.read()); const contexts = [ - ...(topologyResult.state === 'unavailable' ? [topologyResult.reason] : []), + ...(intentResult.state === 'unavailable' ? [intentResult.reason] : []), ...(noticeResult.state === 'available' ? deliveryContexts(noticeResult.value) : [noticeResult.reason]), @@ -59,31 +61,19 @@ export default async function AgentStart({ ); } - const topologyResult = await withTopology(async (topology) => { - await topology.dispatch('actorObserved', { - id: child.id, - kind: 'child', - parentSessionId: child.parentSessionId, - provenance: { - id: child.source, - parentSessionId: child.source, - }, - status: 'active', - }, { - idempotencyKey: `${canonical.idempotencyKey}:actor`, - }); - await topology.dispatch('actorBound', { + const intentResult = await withIntent(async (intent) => { + await intent.dispatch('actorBound', { actorId: child.id, - provenance: 'native', + provenance: { actorId: child.source, worktreeRoot: 'native' }, worktreeRoot: currentWorktree.root, }, { idempotencyKey: `${canonical.idempotencyKey}:worktree`, }); }); - if (topologyResult.state === 'unavailable') { + if (intentResult.state === 'unavailable') { return ( - {topologyResult.reason} + {intentResult.reason} ); } diff --git a/examples/worktree-proximity/src/events/agent/stop.tsx b/examples/worktree-proximity/src/events/agent/stop.tsx new file mode 100644 index 000000000..119835efd --- /dev/null +++ b/examples/worktree-proximity/src/events/agent/stop.tsx @@ -0,0 +1,58 @@ +import { Agent } from '@agent-bundle/runtime'; +import type { AgentEventRouteProps } from 'agent-bundle'; +import React from 'react'; + +import { withIntent, withNotices } from '../../coordination.js'; +import { carriedChild, deliveryContexts, nativeString } from '../../event-support.js'; + +export const config = { + runtime: 'shared', + targets: ['claude', 'codex'], +}; + +/** + * A subagent finished. Routing this family is what lets the runtime's lineage + * registry mark the child stopped — from then on no sibling lists it in + * `request.lineage.tree` — and what releases the child's worktree binding and + * any intent it left behind, so a stale path claim never warns anyone. + */ +export default async function AgentStop({ + canonical, + native, +}: AgentEventRouteProps) { + const child = await carriedChild(native); + if (child === undefined) { + return ( + + + {`Child stop ignored: agent/stop omitted native ${nativeString(native, 'agent_id') === undefined ? 'agent_id' : 'session_id'}; refused to release an actor by guess.`} + + + ); + } + const intentResult = await withIntent(async (intent) => { + await intent.dispatch('actorReleased', { + actorId: child.id, + observedAt: canonical.observedAt, + }, { + idempotencyKey: `${canonical.idempotencyKey}:released`, + }); + }); + if (intentResult.state === 'unavailable') { + return ( + + {intentResult.reason} + + ); + } + const noticeResult = await withNotices(async (notices) => notices.read()); + const contexts = noticeResult.state === 'available' + ? deliveryContexts(noticeResult.value) + : [noticeResult.reason]; + return ( + + {contexts.map((context) => + {context})} + + ); +} diff --git a/examples/worktree-proximity/src/events/session/start.tsx b/examples/worktree-proximity/src/events/session/start.tsx index b0de43550..0e06526d8 100644 --- a/examples/worktree-proximity/src/events/session/start.tsx +++ b/examples/worktree-proximity/src/events/session/start.tsx @@ -3,7 +3,7 @@ import type { AgentEventRouteProps } from 'agent-bundle'; import React from 'react'; import { worktree } from '../../api.js'; -import { withNotices, withTopology } from '../../coordination.js'; +import { withIntent, withNotices } from '../../coordination.js'; import { deliveryContexts, nativeString, requestLineage } from '../../event-support.js'; export const config = { @@ -24,7 +24,9 @@ export default async function SessionStart({ ); } // The runtime's lineage names the root conversation on every host; the - // native `session_id` is the fallback when no lineage was resolved. + // native `session_id` is the fallback when no lineage was resolved. The + // root actor is that conversation itself, so the binding it gets here is + // keyed by the same id `request.lineage.tree` lists it under everywhere else. const lineage = await requestLineage(); const root = lineage.state === 'available' ? { id: lineage.value.root, source: lineage.value.resolution } @@ -40,28 +42,19 @@ export default async function SessionStart({ ); } - const actorId = `session:${root.id}`; - const topologyResult = await withTopology(async (topology) => { - await topology.dispatch('actorObserved', { - id: actorId, - kind: 'root', - provenance: { id: root.source }, - status: 'active', - }, { - idempotencyKey: `${canonical.idempotencyKey}:actor`, - }); - await topology.dispatch('actorBound', { - actorId, - provenance: 'native', + const intentResult = await withIntent(async (intent) => { + await intent.dispatch('actorBound', { + actorId: root.id, + provenance: { actorId: root.source, worktreeRoot: 'native' }, worktreeRoot: currentWorktree.root, }, { idempotencyKey: `${canonical.idempotencyKey}:worktree`, }); }); - if (topologyResult.state === 'unavailable') { + if (intentResult.state === 'unavailable') { return ( - {topologyResult.reason} + {intentResult.reason} ); } diff --git a/examples/worktree-proximity/src/events/stop.tsx b/examples/worktree-proximity/src/events/stop.tsx index acf897da3..03c0f8cfa 100644 --- a/examples/worktree-proximity/src/events/stop.tsx +++ b/examples/worktree-proximity/src/events/stop.tsx @@ -3,7 +3,7 @@ import type { AgentEventRouteProps } from 'agent-bundle'; import React from 'react'; import { worktree } from '../api.js'; -import { withNotices, withTopology } from '../coordination.js'; +import { withIntent, withNotices } from '../coordination.js'; import { actorForWorktree, carriedChild, @@ -30,23 +30,25 @@ export default async function Stop({ } // A stop names its own actor through the runtime lineage or the native // `agent_id`; only an anonymous stop falls back to the worktree binding. + // Releasing the actor drops its binding and any intent it still held; the + // runtime's lineage registry records the stop itself. const carried = await carriedChild(native); - const topologyResult = await withTopology(async (topology): Promise => { + const intentResult = await withIntent(async (intent): Promise => { const resolved: ResolvedActor = carried === undefined - ? (await actorForWorktree(topology, currentWorktree, canonical)).actor + ? (await actorForWorktree(intent, currentWorktree, canonical)).actor : { id: carried.id, source: carried.source }; - await topology.dispatch('actorStopped', { + await intent.dispatch('actorReleased', { actorId: resolved.id, observedAt: canonical.observedAt, }, { - idempotencyKey: `${canonical.idempotencyKey}:stopped`, + idempotencyKey: `${canonical.idempotencyKey}:released`, }); return resolved; }); - if (topologyResult.state === 'unavailable') { + if (intentResult.state === 'unavailable') { return ( - {topologyResult.reason} + {intentResult.reason} ); } diff --git a/examples/worktree-proximity/src/events/tool/after.tsx b/examples/worktree-proximity/src/events/tool/after.tsx index 7e2ce4709..c35d70ce9 100644 --- a/examples/worktree-proximity/src/events/tool/after.tsx +++ b/examples/worktree-proximity/src/events/tool/after.tsx @@ -3,7 +3,7 @@ import type { AgentEventRouteProps } from 'agent-bundle'; import React from 'react'; import { worktree } from '../../api.js'; -import { withNotices, withTopology } from '../../coordination.js'; +import { withIntent, withNotices } from '../../coordination.js'; import { actorForWorktree, deliveryContexts } from '../../event-support.js'; export const config = { @@ -23,9 +23,9 @@ export default async function AfterTool({ ); } - const topologyResult = await withTopology(async (topology) => { - const resolved = await actorForWorktree(topology, currentWorktree, canonical, native); - await topology.dispatch('intentRecorded', { + const intentResult = await withIntent(async (intent) => { + const resolved = await actorForWorktree(intent, currentWorktree, canonical, native); + await intent.dispatch('intentRecorded', { actorId: resolved.actor.id, dependencies: [], idempotencyKey: canonical.idempotencyKey, @@ -41,10 +41,10 @@ export default async function AfterTool({ }); return resolved.actor; }); - if (topologyResult.state === 'unavailable') { + if (intentResult.state === 'unavailable') { return ( - {topologyResult.reason} + {intentResult.reason} ); } diff --git a/examples/worktree-proximity/src/events/tool/before.tsx b/examples/worktree-proximity/src/events/tool/before.tsx index 3990e6a65..3eac4b07c 100644 --- a/examples/worktree-proximity/src/events/tool/before.tsx +++ b/examples/worktree-proximity/src/events/tool/before.tsx @@ -3,12 +3,14 @@ import type { AgentEventRouteProps } from 'agent-bundle'; import React from 'react'; import { worktree } from '../../api.js'; -import { withNotices, withTopology } from '../../coordination.js'; +import { withIntent, withNotices } from '../../coordination.js'; import { findProximity } from '../../domain/proximity.js'; import { actorForWorktree, deliveryContexts, extractIntent, + liveConversations, + requestLineage, } from '../../event-support.js'; export const config = { @@ -29,9 +31,13 @@ export default async function BeforeTool({ ); } const intent = extractIntent(native); - const topologyResult = await withTopology(async (topology) => { - const { actor } = await actorForWorktree(topology, currentWorktree, canonical, native); - const committed = await topology.dispatch('intentRecorded', { + // Who else is alive comes from the runtime's lineage registry, not from + // this application's bookkeeping: an intent left behind by an agent the + // registry no longer lists under our root is stale and warns nobody. + const live = liveConversations(await requestLineage()); + const intentResult = await withIntent(async (store) => { + const { actor } = await actorForWorktree(store, currentWorktree, canonical, native); + const committed = await store.dispatch('intentRecorded', { actorId: actor.id, dependencies: [...intent.dependencies], idempotencyKey: canonical.idempotencyKey, @@ -51,17 +57,17 @@ export default async function BeforeTool({ actorId: actor.id, dependencies: intent.dependencies, paths: intent.paths, - }), + }, live === undefined ? {} : { liveConversations: live }), }; }); - if (topologyResult.state === 'unavailable') { + if (intentResult.state === 'unavailable') { return ( - {topologyResult.reason} + {intentResult.reason} ); } - const resolution = topologyResult.value; + const resolution = intentResult.value; const noticeResult = await withNotices(async (notices) => { const deliveries = await notices.read(); diff --git a/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx b/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx index cf1965e98..f4aef3ded 100644 --- a/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx +++ b/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx @@ -3,12 +3,13 @@ import type { ToolConfig, ToolRouteProps } from 'agent-bundle'; import React from 'react'; import { z } from 'zod'; -import { withTopology } from '../../../coordination.js'; -import { ActorSchema } from '../../../state.js'; +import { withIntent } from '../../../coordination.js'; +import { agentTree } from '../../../event-support.js'; +import { ActivitySchema, BindingSchema } from '../../../state.js'; export const config = { annotations: { readOnlyHint: true }, - description: 'Show the mounted durable worktree topology, active intents, and refusals.', + description: 'Show the live agent tree the runtime resolved for this call, the worktree bindings, active intents, and refusals.', } satisfies ToolConfig; export const inputSchema = z @@ -17,10 +18,56 @@ export const inputSchema = z }) .strict(); +const resolutionSchema = z.enum(['native', 'registry', 'confirmed', 'transcript', 'inferred']); + +const peerSchema = z + .object({ + conversation: z.string().min(1), + depth: z.number().int().nonnegative(), + parent: z.string().min(1).optional(), + resolution: resolutionSchema, + startedAt: z.string().min(1), + subagent: z + .object({ + id: z.string().min(1), + isParallelWorker: z.boolean().optional(), + toolCallId: z.string().min(1).optional(), + type: z.string().min(1).optional(), + }) + .strict() + .optional(), + }) + .strict(); + +/** The agent tree as the runtime's lineage registry holds it around this call; never this application's guess. */ +export const agentsSchema = z.discriminatedUnion('state', [ + z + .object({ + children: z.array(peerSchema).readonly(), + conversation: z.string().min(1), + depth: z.number().int().nonnegative(), + parent: z.string().min(1).optional(), + resolution: resolutionSchema, + root: z.string().min(1), + roots: z.array(peerSchema).readonly(), + siblings: z.array(peerSchema).readonly(), + state: z.literal('available'), + }) + .strict(), + z + .object({ + reason: z.string().min(1), + state: z.literal('unavailable'), + }) + .strict(), +]); + export const resultSchema = z .object({ activeActivities: z.number().int().nonnegative(), - actors: z.array(ActorSchema), + activities: z.array(ActivitySchema), + agents: agentsSchema, + bindings: z.array(BindingSchema), reason: z.string().optional(), refusals: z.number().int().nonnegative(), revision: z.number().int().nonnegative(), @@ -33,45 +80,62 @@ type StatusResult = z.output; export default async function Status({ input, }: ToolRouteProps) { - const topologyResult = await withTopology(async (store) => store.read()); + const agents = await agentTree(); + const intentResult = await withIntent(async (store) => store.read()); let result: StatusResult; - if (topologyResult.state === 'unavailable') { + if (intentResult.state === 'unavailable') { result = { activeActivities: 0, - actors: [], - reason: topologyResult.reason, + activities: [], + agents, + bindings: [], + reason: intentResult.reason, refusals: 0, revision: 0, state: 'unavailable', }; } else { - const { revision, state: topology } = topologyResult.value; - const actors = input.actorId === undefined - ? topology.actors - : topology.actors.filter((actor) => actor.id === input.actorId); - const visibleIds = new Set(actors.map((actor) => actor.id)); + const { revision, state: intent } = intentResult.value; + const bindings = input.actorId === undefined + ? intent.bindings + : intent.bindings.filter((binding) => binding.actorId === input.actorId); + const visibleIds = new Set(bindings.map((binding) => binding.actorId)); + const activities = intent.activities.filter((activity) => visibleIds.has(activity.actorId)); result = { - activeActivities: topology.activities.filter( - (activity) => - visibleIds.has(activity.actorId) - && (activity.paths.length > 0 || activity.dependencies.length > 0), + activeActivities: activities.filter( + (activity) => activity.paths.length > 0 || activity.dependencies.length > 0, ).length, - actors, - refusals: topology.refusals.length, + activities, + agents, + bindings, + refusals: intent.refusals.length, revision, state: 'available', }; } + const agentLines = result.agents.state === 'available' + ? [ + `- This call: ${result.agents.conversation} at depth ${String(result.agents.depth)} under ${result.agents.root} (${result.agents.resolution})`, + `- Live under the same root: ${result.agents.siblings.length === 0 ? 'none' : result.agents.siblings.map((peer) => `${peer.conversation} (depth ${String(peer.depth)}, ${peer.resolution})`).join(', ')}`, + `- Children: ${String(result.agents.children.length)}; other live roots: ${String(result.agents.roots.length)}`, + ] + : [`- Agent tree unavailable: ${result.agents.reason}`]; const markdown = result.state === 'available' ? [ '# Worktree proximity status', '', - `- Actors: ${String(result.actors.length)}`, + ...agentLines, + `- Worktree bindings: ${String(result.bindings.length)}`, `- Active activities: ${String(result.activeActivities)}`, `- Refused edges: ${String(result.refusals)}`, ].join('\n') - : `# Worktree proximity status\n\nUnavailable: ${result.reason ?? 'unknown reason'}`; + : [ + '# Worktree proximity status', + '', + ...agentLines, + `- Intent state unavailable: ${result.reason ?? 'unknown reason'}`, + ].join('\n'); return ( {markdown} diff --git a/examples/worktree-proximity/src/providers/agent-topology.ts b/examples/worktree-proximity/src/providers/agent-topology.ts index 2c8afdc81..ba2335799 100644 --- a/examples/worktree-proximity/src/providers/agent-topology.ts +++ b/examples/worktree-proximity/src/providers/agent-topology.ts @@ -4,19 +4,19 @@ export interface AgentTopologyProviderValue { } /** - * The issue sketch places a topology snapshot at - * `providers.agentTopology.snapshot`. A conventional provider factory still - * receives only `{ invocation, signal }` — no request identity, no - * `lineage`, and no mounted `state`/`notices` handles (agent-bundle#459) — - * so it cannot derive that view. Routes read the topology from - * `(await agent()).state` and their own place in the conversation tree from - * `(await agent()).lineage` instead; this provider reports the gap honestly - * rather than opening a second store. + * The issue sketch places the agent tree at `providers.agentTopology`. The + * tree itself is now on the request — `(await agent()).lineage.value.tree` + * lists the live siblings, children, and other roots the runtime's registry + * holds (agent-bundle#457) — but a conventional provider factory still + * receives only `{ invocation, signal }`, not the request's `lineage` + * (agent-bundle#459), so this provider cannot derive that view. Routes read + * `request.lineage` directly (`agentTree()` in `event-support.ts`); this + * provider reports the gap honestly rather than inventing a tree. */ export default function agentTopologyProvider(): AgentTopologyProviderValue { return { reason: - 'Topology snapshots are available only from the mounted request state handle; providers receive no request identity, lineage, or state handle (agent-bundle#459).', + 'The agent tree is on request.lineage.tree; providers receive no request lineage (agent-bundle#459), so read it from (await agent()).lineage in a route.', state: 'unavailable', }; } diff --git a/examples/worktree-proximity/src/state.ts b/examples/worktree-proximity/src/state.ts index e054acf6c..eb229d093 100644 --- a/examples/worktree-proximity/src/state.ts +++ b/examples/worktree-proximity/src/state.ts @@ -14,20 +14,22 @@ const provenance = z.enum(['native', 'registry', 'inferred', 'confirmed', 'trans export type IdentityProvenance = z.output; -export const ActorSchema = z +/** + * Which worktree an actor works in. This is the one fact about an actor the + * runtime's lineage registry cannot know — the agent tree itself (parent, + * root, depth, who is alive) is `(await agent()).lineage`, so nothing about + * it is recorded here. + */ +export const BindingSchema = z .object({ - id: nonEmpty, - kind: z.enum(['root', 'child']), - parentSessionId: nonEmpty.optional(), + actorId: nonEmpty, provenance: z .object({ - id: provenance, - parentSessionId: provenance.optional(), - worktreeRoot: provenance.optional(), + actorId: provenance, + worktreeRoot: provenance, }) .strict(), - status: z.enum(['active', 'stopped']), - worktreeRoot: nonEmpty.optional(), + worktreeRoot: nonEmpty, }) .strict(); @@ -57,90 +59,54 @@ export const EdgeRefusalSchema = z }) .strict(); -export const TopologyStateSchema = z +export const IntentStateSchema = z .object({ activities: z.array(ActivitySchema), - actors: z.array(ActorSchema), + bindings: z.array(BindingSchema), refusals: z.array(EdgeRefusalSchema), }) .strict(); -export type Actor = z.output; +export type Binding = z.output; export type Activity = z.output; -export type TopologyState = z.output; +export type IntentState = z.output; -const actorObservedSchema = ActorSchema.omit({ worktreeRoot: true }).strict(); -const actorBoundSchema = z - .object({ - actorId: nonEmpty, - provenance, - worktreeRoot: nonEmpty, - }) - .strict(); -const actorStoppedSchema = z +const actorReleasedSchema = z .object({ actorId: nonEmpty, observedAt: nonEmpty, }) .strict(); -export const topologyEventSchemas = { - actorBound: actorBoundSchema, - actorObserved: actorObservedSchema, - actorStopped: actorStoppedSchema, +export const intentEventSchemas = { + /** An actor was seen working in a worktree; a later binding for the same actor replaces the earlier one. */ + actorBound: BindingSchema, + /** The actor stopped: its binding and any intent it still held are gone. */ + actorReleased: actorReleasedSchema, edgeRefused: EdgeRefusalSchema, intentRecorded: ActivitySchema, } as const; -export type TopologyEvents = typeof topologyEventSchemas; - -const replaceActor = ( - actors: readonly Actor[], - actorId: string, - update: (actor: Actor) => Actor, -): Actor[] => actors.map((actor) => actor.id === actorId ? update(actor) : actor); +export type IntentEvents = typeof intentEventSchemas; -export const topologyStateDefinition = defineState({ - events: topologyEventSchemas, - id: 'worktree-proximity/topology', +export const intentStateDefinition = defineState({ + events: intentEventSchemas, + id: 'worktree-proximity/intent', initial: { activities: [], - actors: [], + bindings: [], refusals: [], }, lifetime: 'workspace-durable', - reduce: (state, event): TopologyState => { + reduce: (state, event): IntentState => { switch (event.name) { - case 'actorObserved': { - const previous = state.actors.find((actor) => actor.id === event.payload.id); - const actor = previous === undefined - ? event.payload - : { - ...event.payload, - ...(previous.worktreeRoot === undefined ? {} : { worktreeRoot: previous.worktreeRoot }), - provenance: { - ...event.payload.provenance, - ...(previous.provenance.worktreeRoot === undefined - ? {} - : { worktreeRoot: previous.provenance.worktreeRoot }), - }, - }; - return { - ...state, - actors: [...state.actors.filter((candidate) => candidate.id !== actor.id), actor], - }; - } case 'actorBound': return { ...state, - actors: replaceActor(state.actors, event.payload.actorId, (actor) => ({ - ...actor, - provenance: { - ...actor.provenance, - worktreeRoot: event.payload.provenance, - }, - worktreeRoot: event.payload.worktreeRoot, - })), + bindings: [ + ...state.bindings.filter((binding) => binding.actorId !== event.payload.actorId), + event.payload, + ], }; case 'intentRecorded': return { @@ -150,14 +116,11 @@ export const topologyStateDefinition = defineState({ event.payload, ], }; - case 'actorStopped': + case 'actorReleased': return { ...state, activities: state.activities.filter((activity) => activity.actorId !== event.payload.actorId), - actors: replaceActor(state.actors, event.payload.actorId, (actor) => ({ - ...actor, - status: 'stopped', - })), + bindings: state.bindings.filter((binding) => binding.actorId !== event.payload.actorId), }; case 'edgeRefused': return { @@ -166,16 +129,16 @@ export const topologyStateDefinition = defineState({ }; default: { const unreachable: never = event; - throw new Error(`Unhandled topology event ${String(unreachable)}`); + throw new Error(`Unhandled intent event ${String(unreachable)}`); } } }, - schema: TopologyStateSchema, + schema: IntentStateSchema, version: 1, }); export default defineState({ - ...topologyStateDefinition, - id: 'worktree-proximity/topology', + ...intentStateDefinition, + id: 'worktree-proximity/intent', lifetime: 'workspace-durable', }); diff --git a/examples/worktree-proximity/tests/proximity.test.ts b/examples/worktree-proximity/tests/proximity.test.ts index 3d594bd7e..57436981c 100644 --- a/examples/worktree-proximity/tests/proximity.test.ts +++ b/examples/worktree-proximity/tests/proximity.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from '@rstest/core'; import { findProximity } from '../src/domain/proximity.js'; -import type { TopologyState } from '../src/state.js'; +import type { IntentState } from '../src/state.js'; -const snapshot = (path: string, dependency: string): TopologyState => ({ +const snapshot = (path: string, dependency: string): IntentState => ({ activities: [{ actorId: 'agent-b', dependencies: [dependency], @@ -16,29 +16,15 @@ const snapshot = (path: string, dependency: string): TopologyState => ({ paths: 'native', }, }], - actors: [ + bindings: [ { - id: 'agent-a', - kind: 'child', - parentSessionId: 'root-session', - provenance: { - id: 'native', - parentSessionId: 'native', - worktreeRoot: 'native', - }, - status: 'active', + actorId: 'agent-a', + provenance: { actorId: 'native', worktreeRoot: 'native' }, worktreeRoot: '/repo/worktrees/a', }, { - id: 'agent-b', - kind: 'child', - parentSessionId: 'root-session', - provenance: { - id: 'native', - parentSessionId: 'native', - worktreeRoot: 'native', - }, - status: 'active', + actorId: 'agent-b', + provenance: { actorId: 'native', worktreeRoot: 'native' }, worktreeRoot: '/repo/worktrees/b', }, ], @@ -81,10 +67,10 @@ describe('findProximity', () => { }); it('ignores an actor in the same worktree', () => { - const sameWorktree: TopologyState = { + const sameWorktree: IntentState = { ...snapshot('src/shared.ts', 'zod'), - actors: snapshot('src/shared.ts', 'zod').actors.map((actor) => ({ - ...actor, + bindings: snapshot('src/shared.ts', 'zod').bindings.map((binding) => ({ + ...binding, worktreeRoot: '/repo/worktrees/a', })), }; @@ -94,4 +80,30 @@ describe('findProximity', () => { { actorId: 'agent-a', dependencies: ['zod'], paths: ['src/shared.ts'] }, )).toEqual([]); }); + + it('ignores an intent whose host-identified actor the runtime lineage tree no longer lists as alive', () => { + const state = snapshot('src/shared.ts', 'zod'); + const intent = { actorId: 'agent-a', dependencies: [], paths: ['src/shared.ts'] }; + expect(findProximity(state, '/repo/worktrees/a', intent, { liveConversations: new Set(['agent-a', 'root-session']) })).toEqual([]); + expect(findProximity(state, '/repo/worktrees/a', intent, { liveConversations: new Set(['agent-a', 'agent-b']) })).toHaveLength(1); + // Without a tree nothing is presumed stopped. + expect(findProximity(state, '/repo/worktrees/a', intent)).toHaveLength(1); + }); + + it('never filters a derived worktree actor by the lineage tree: it is not a conversation', () => { + const base = snapshot('src/shared.ts', 'zod'); + const state: IntentState = { + ...base, + activities: base.activities.map((activity) => ({ ...activity, actorId: 'worktree:/repo/worktrees/b', provenance: { ...activity.provenance, actorId: 'derived' } })), + bindings: base.bindings.map((binding) => binding.actorId === 'agent-b' + ? { actorId: 'worktree:/repo/worktrees/b', provenance: { actorId: 'derived', worktreeRoot: 'derived' }, worktreeRoot: binding.worktreeRoot } + : binding), + }; + expect(findProximity( + state, + '/repo/worktrees/a', + { actorId: 'agent-a', dependencies: [], paths: ['src/shared.ts'] }, + { liveConversations: new Set(['agent-a']) }, + )).toHaveLength(1); + }); }); diff --git a/examples/worktree-proximity/tests/route-unit/routes.test.ts b/examples/worktree-proximity/tests/route-unit/routes.test.ts index 70c5ca956..da1ccad89 100644 --- a/examples/worktree-proximity/tests/route-unit/routes.test.ts +++ b/examples/worktree-proximity/tests/route-unit/routes.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, it } from '@rstest/core'; -import { available, type AgentLineage, type Observed } from '@agent-bundle/runtime'; +import { available, type AgentLineage, type AgentLineagePeer, type Observed } from '@agent-bundle/runtime'; import { expectDocument, mountTestState, renderRoute, testManifest, type MountedTestState } from 'agent-bundle/test'; import BeforeTool from '../../src/events/tool/before.js'; import agentTopologyProvider from '../../src/providers/agent-topology.js'; -import type { TopologyEvents, TopologyState } from '../../src/state.js'; +import type { IntentEvents, IntentState } from '../../src/state.js'; const manifest = testManifest(); @@ -17,7 +17,7 @@ const worktrees = { // One mounted topology state (and notice ledger) per test: every event in a // journey records into it and the assertions read it back, exactly as one // generated runtime would serve the whole session. -let mounted: MountedTestState; +let mounted: MountedTestState; let sequence = 0; const provider = (root: string) => ({ @@ -40,7 +40,7 @@ const providers = (root: string) => ({ }); const eventInput = ( - event: 'agent/start' | 'session/start' | 'stop' | 'tool/after' | 'tool/before', + event: 'agent/start' | 'agent/stop' | 'session/start' | 'stop' | 'tool/after' | 'tool/before', native: Record, id: string, ) => ({ @@ -110,6 +110,26 @@ const childLineage = (id: string): Observed => available({ subagent: { id }, }, 'derived'); +const rootPeer: AgentLineagePeer = { conversation: 'root-session', depth: 0, resolution: 'native', startedAt: '2026-09-01T19:59:00.000Z' }; +const childPeer = (id: string): AgentLineagePeer => ({ + conversation: id, + depth: 1, + parent: 'root-session', + resolution: 'registry', + startedAt: '2026-09-01T19:59:30.000Z', + subagent: { id, type: 'implementation' }, +}); + +/** The same child lineage carrying the registry's live tree: the root plus the named live siblings. */ +const childLineageWithTree = (id: string, liveSiblings: readonly string[]): Observed => { + const own = childLineage(id); + if (own.state !== 'available') throw new Error('unreachable'); + return available({ + ...own.value, + tree: { children: [], roots: [], siblings: [rootPeer, ...liveSiblings.map(childPeer)] }, + }, 'derived'); +}; + const bindActors = async (): Promise => { await renderEvent( 'event:session/start', @@ -117,7 +137,7 @@ const bindActors = async (): Promise => { { cwd: worktrees.root, hook_event_name: 'SessionStart', session_id: 'root-session' }, 'root:start', worktrees.root, - 'session:root-session', + 'root-session', ); await renderEvent( 'event:agent/start', @@ -171,7 +191,7 @@ const recordIntent = ( ); beforeEach(async () => { - mounted = await mountTestState(); + mounted = await mountTestState(); sequence = 0; }); @@ -183,6 +203,7 @@ it('compiles the complete shared-runtime route surface', () => { expect(manifest.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); expect(Object.keys(manifest.routes)).toEqual(expect.arrayContaining([ 'event:agent/start', + 'event:agent/stop', 'event:session/start', 'event:stop', 'event:tool/after', @@ -311,7 +332,7 @@ describe('worktree proximity journeys', () => { .toContainContext('refused to fabricate'); const snapshot = await mounted.read(); - expect(snapshot.state.actors).toEqual([]); + expect(snapshot.state.bindings).toEqual([]); expect(snapshot.state.refusals).toEqual([ expect.objectContaining({ reason: 'agent/start omitted native agent_id; refused to fabricate a topology edge', @@ -340,14 +361,9 @@ describe('worktree proximity journeys', () => { const snapshot = await mounted.read(); expect(snapshot.state.refusals).toEqual([]); - expect(snapshot.state.actors).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: 'agent-c', - kind: 'child', - parentSessionId: 'root-session', - provenance: expect.objectContaining({ id: 'registry', parentSessionId: 'registry' }), - worktreeRoot: worktrees.b, - }), + // The edge (parent, depth) is the registry's; the application records only the worktree. + expect(snapshot.state.bindings).toEqual(expect.arrayContaining([ + { actorId: 'agent-c', provenance: { actorId: 'registry', worktreeRoot: 'native' }, worktreeRoot: worktrees.b }, ])); }); @@ -373,17 +389,13 @@ describe('worktree proximity journeys', () => { expect(rendered.document.value).toEqual({ outcome: 'continue' }); const snapshot = await mounted.read(); - expect(snapshot.state.actors).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: 'agent-c', - provenance: expect.objectContaining({ id: 'registry', parentSessionId: 'registry' }), - worktreeRoot: worktrees.a, - }), + expect(snapshot.state.bindings).toEqual(expect.arrayContaining([ + { actorId: 'agent-c', provenance: { actorId: 'registry', worktreeRoot: 'native' }, worktreeRoot: worktrees.a }, ])); expect(snapshot.state.activities.map((activity) => activity.actorId)).toEqual(['agent-c']); }); - it('renders the coordinator status from mounted topology state', async () => { + it('renders the coordinator status from mounted intent state, with the agent tree honestly absent without a lineage', async () => { await bindActors(); await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); @@ -398,16 +410,113 @@ describe('worktree proximity journeys', () => { expectDocument(rendered) .toHaveStatus('success') - .toContainMarkdown('Worktree proximity status'); + .toContainMarkdown('Worktree proximity status') + .toContainMarkdown('Agent tree unavailable: lineage unavailable (not-provided)'); expect(rendered.result).toMatchObject({ activeActivities: 2, - actors: expect.arrayContaining([ - expect.objectContaining({ id: 'agent-a', worktreeRoot: worktrees.a }), - expect.objectContaining({ id: 'agent-b', worktreeRoot: worktrees.b }), + agents: { reason: 'lineage unavailable (not-provided)', state: 'unavailable' }, + bindings: expect.arrayContaining([ + expect.objectContaining({ actorId: 'agent-a', worktreeRoot: worktrees.a }), + expect.objectContaining({ actorId: 'agent-b', worktreeRoot: worktrees.b }), ]), refusals: 0, revision: expect.any(Number), }); + expect((rendered.result as { bindings: readonly { actorId: string }[] }).bindings.map((binding) => binding.actorId)) + .toEqual(['root-session', 'agent-a', 'agent-b']); + }); + + it('renders the live agent tree the runtime resolved for the call, never a tree of its own (#457)', async () => { + const rendered = await renderRoute('tool:coordinator/status', { + context: { + ...mounted.context(), + lineage: childLineageWithTree('agent-a', ['agent-b']), + providers: providers(worktrees.a), + }, + input: {}, + }); + expectDocument(rendered) + .toHaveStatus('success') + .toContainMarkdown('This call: agent-a at depth 1 under root-session (registry)') + .toContainMarkdown('root-session (depth 0, native), agent-b (depth 1, registry)'); + expect(rendered.result).toMatchObject({ + agents: { + children: [], + conversation: 'agent-a', + depth: 1, + parent: 'root-session', + resolution: 'registry', + root: 'root-session', + roots: [], + siblings: [rootPeer, childPeer('agent-b')], + state: 'available', + }, + bindings: [], + state: 'available', + }); + }); + + it('warns about a sibling the lineage tree lists as alive and stops once the tree drops it (#457)', async () => { + await bindActors(); + await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); + const intentB = (id: string, lineage: Observed) => renderEvent( + 'event:tool/before', + 'tool/before', + { + cwd: worktrees.b, + hook_event_name: 'PreToolUse', + session_id: 'root-session', + tool_input: { file_path: 'src/shared.ts' }, + tool_name: 'Edit', + }, + id, + worktrees.b, + undefined, + lineage, + ); + // agent-a is alive under the shared root: its claim on src/shared.ts is a real conflict. + const alive = await intentB('intent:b:alive', childLineageWithTree('agent-b', ['agent-a'])); + expectDocument(alive).toHaveStatus('success').toContainContext('Proximity warning for agent-b'); + // The registry no longer lists agent-a (it stopped): the same recorded intent is stale and warns nobody. + const gone = await intentB('intent:b:gone', childLineageWithTree('agent-b', [])); + expectDocument(gone).toHaveStatus('success').toHaveNodeKinds(['result']); + expect(gone.document.value).toEqual({ outcome: 'continue' }); + // A lineage without a tree presumes nothing about who stopped. + const unknown = await intentB('intent:b:unknown', childLineage('agent-b')); + expectDocument(unknown).toHaveStatus('success').toContainContext('Proximity warning for agent-b'); + }); + + it('releases a child on agent/stop: its binding and intent go, and the notice ledger is read as usual', async () => { + await bindActors(); + await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); + const rendered = await renderEvent( + 'event:agent/stop', + 'agent/stop', + { + agent_id: 'agent-a', + agent_type: 'implementation', + cwd: worktrees.a, + hook_event_name: 'SubagentStop', + session_id: 'root-session', + }, + 'agent-a:stop', + worktrees.a, + 'agent-a', + ); + expectDocument(rendered).toHaveStatus('success').toHaveNodeKinds(['result']); + + const snapshot = await mounted.read(); + expect(snapshot.state.bindings.map((binding) => binding.actorId)).toEqual(['root-session', 'agent-b']); + expect(snapshot.state.activities).toEqual([]); + // An identity-less stop releases nobody. + const anonymous = await renderEvent( + 'event:agent/stop', + 'agent/stop', + { agent_type: 'implementation', cwd: worktrees.b, hook_event_name: 'SubagentStop', session_id: 'root-session' }, + 'agent-?:stop', + worktrees.b, + ); + expectDocument(anonymous).toHaveStatus('success').toContainContext('refused to release an actor by guess'); }); it('renders state unavailability when an event module has no mounted handle', async () => { diff --git a/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts b/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts index 1759ee3e8..3dd44a3f8 100644 --- a/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts +++ b/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts @@ -15,22 +15,43 @@ const execFile = promisify(executeFile); const exampleRoot = resolve(import.meta.dirname, '../../../examples/worktree-proximity'); const sessionId = 'root-session'; -interface ActorStatus { - readonly id: string; - readonly kind: 'child' | 'root'; - readonly parentSessionId?: string; +interface BindingStatus { + readonly actorId: string; readonly provenance: { - readonly id: 'derived' | 'native'; - readonly parentSessionId?: 'derived' | 'native'; - readonly worktreeRoot?: 'derived' | 'native'; + readonly actorId: 'derived' | 'native' | 'registry' | 'confirmed' | 'inferred' | 'transcript'; + readonly worktreeRoot: 'derived' | 'native'; }; - readonly status: 'active' | 'stopped'; - readonly worktreeRoot?: string; + readonly worktreeRoot: string; } +interface PeerStatus { + readonly conversation: string; + readonly depth: number; + readonly parent?: string; + readonly resolution: string; + readonly startedAt: string; + readonly subagent?: { readonly id: string; readonly toolCallId?: string; readonly type?: string }; +} + +type AgentsStatus = + | { + readonly children: readonly PeerStatus[]; + readonly conversation: string; + readonly depth: number; + readonly parent?: string; + readonly resolution: string; + readonly root: string; + readonly roots: readonly PeerStatus[]; + readonly siblings: readonly PeerStatus[]; + readonly state: 'available'; + } + | { readonly reason: string; readonly state: 'unavailable' }; + interface StatusResult { readonly activeActivities: number; - readonly actors: readonly ActorStatus[]; + readonly activities: readonly { readonly actorId: string; readonly paths: readonly string[] }[]; + readonly agents: AgentsStatus; + readonly bindings: readonly BindingStatus[]; readonly reason?: string; readonly refusals: number; readonly revision: number; @@ -43,6 +64,7 @@ interface JourneyFixture { readonly hooks: { readonly afterTool: string; readonly agentStart: string; + readonly agentStop: string; readonly beforeTool: string; readonly sessionStart: string; }; @@ -158,7 +180,9 @@ const callStatus = async (client: Client): Promise => { expect(result).toBeDefined(); expect(Object.keys(result as Record).sort()).toEqual([ 'activeActivities', - 'actors', + 'activities', + 'agents', + 'bindings', 'refusals', 'revision', 'state', @@ -166,6 +190,37 @@ const callStatus = async (client: Client): Promise => { return result as StatusResult; }; +/** The Claude spelling of the coordinator's `status` tool on a plugin-installed server. */ +const STATUS_TOOL = 'mcp__plugin_worktree-proximity_coordinator__status'; + +/** + * Calls `status` the way a host conversation does: inside the root's own + * pre-/post-tool hook window, so the generated server correlates the call to + * the root conversation and `request.lineage` — and its tree — resolves. + */ +const callStatusAsRoot = async ( + client: Client, + repoRoot: string, + toolUseId: string, + env: Readonly>, + transcriptPath: string, +): Promise => { + const envelope = { + cwd: repoRoot, + session_id: sessionId, + tool_input: {}, + tool_name: STATUS_TOOL, + tool_use_id: toolUseId, + transcript_path: transcriptPath, + }; + await runHook(fixture.hooks.beforeTool, repoRoot, { ...envelope, hook_event_name: 'PreToolUse' }, env); + try { + return await callStatus(client); + } finally { + await runHook(fixture.hooks.afterTool, repoRoot, { ...envelope, hook_event_name: 'PostToolUse', tool_response: { ok: true } }, env); + } +}; + const hookText = ( output: Readonly> | undefined, ): string => JSON.stringify(output) ?? ''; @@ -216,6 +271,7 @@ beforeAll(async () => { hooks: { afterTool: hook('afterTool'), agentStart: hook('agentStart'), + agentStop: hook('agentStop'), beforeTool: hook('beforeTool'), sessionStart: hook('sessionStart'), }, @@ -254,9 +310,13 @@ it('proves worktree proximity journeys across real processes and linked worktree liveSession = await startServer(); expect(liveSession.pid).toBeGreaterThan(0); await expect(stat(fixture.endpoint)).resolves.toMatchObject({ mode: expect.any(Number) }); + // A client the registry cannot place (no pre-tool hook window names this + // call) gets no agent tree: the server never guesses who is asking. await expect(callStatus(liveSession.client)).resolves.toEqual({ activeActivities: 0, - actors: [], + activities: [], + agents: { reason: 'lineage unavailable (id-not-resolvable)', state: 'unavailable' }, + bindings: [], refusals: 0, revision: 0, state: 'available', @@ -271,22 +331,52 @@ it('proves worktree proximity journeys across real processes and linked worktree source: 'startup', transcript_path: transcriptPath, }, hookEnvironment); - await runHook(fixture.hooks.agentStart, fixture.worktreeA, { - agent_id: 'agent-a', - agent_type: 'implementation', - cwd: fixture.worktreeA, - hook_event_name: 'SubagentStart', - session_id: sessionId, - transcript_path: transcriptPath, - }, hookEnvironment); - await runHook(fixture.hooks.agentStart, fixture.worktreeB, { - agent_id: 'agent-b', - agent_type: 'implementation', - cwd: fixture.worktreeB, - hook_event_name: 'SubagentStart', - session_id: sessionId, - transcript_path: transcriptPath, - }, hookEnvironment); + // Each child is spawned the way Claude does it: the root's `Agent` PreToolUse + // opens the spawn window the registry places the SubagentStart under, and + // the PostToolUse names the child, confirming the edge (#422). + const spawn = async (agentId: string, worktree: string, toolUseId: string): Promise => { + const call = { + cwd: fixture.repoRoot, + session_id: sessionId, + tool_input: { description: agentId, prompt: `work in ${worktree}`, subagent_type: 'implementation' }, + tool_name: 'Agent', + tool_use_id: toolUseId, + transcript_path: transcriptPath, + }; + await runHook(fixture.hooks.beforeTool, fixture.repoRoot, { ...call, hook_event_name: 'PreToolUse' }, hookEnvironment); + await runHook(fixture.hooks.agentStart, worktree, { + agent_id: agentId, + agent_type: 'implementation', + cwd: worktree, + hook_event_name: 'SubagentStart', + session_id: sessionId, + transcript_path: transcriptPath, + }, hookEnvironment); + await runHook(fixture.hooks.afterTool, fixture.repoRoot, { + ...call, + hook_event_name: 'PostToolUse', + tool_response: { agentId, isAsync: true, status: 'async_launched' }, + }, hookEnvironment); + }; + await spawn('agent-a', fixture.worktreeA, 'spawn-a'); + await spawn('agent-b', fixture.worktreeB, 'spawn-b'); + + // The root sees both children alive in the registry's tree, each edge + // host-confirmed, through the same `status` tool the test client called + // blind above. + const rootView = await callStatusAsRoot(liveSession.client, fixture.repoRoot, 'status-1', hookEnvironment, transcriptPath); + expect(rootView.agents).toMatchObject({ + children: [ + { conversation: 'agent-a', depth: 1, parent: sessionId, resolution: 'confirmed', subagent: { id: 'agent-a', toolCallId: 'spawn-a', type: 'implementation' } }, + { conversation: 'agent-b', depth: 1, parent: sessionId, resolution: 'confirmed', subagent: { id: 'agent-b', toolCallId: 'spawn-b', type: 'implementation' } }, + ], + conversation: sessionId, + depth: 0, + root: sessionId, + roots: [], + state: 'available', + }); + expect(rootView.agents.state === 'available' ? rootView.agents.siblings.map((peer) => peer.conversation) : []).toEqual(['agent-a', 'agent-b']); const intentA = { cwd: fixture.worktreeA, @@ -362,43 +452,31 @@ it('proves worktree proximity journeys across real processes and linked worktree ); await expect(callStatus(liveSession.client)).resolves.toEqual(beforeMalformedEnvelope); - const expectedActors: readonly ActorStatus[] = [ + // The application keeps only worktree bindings; the agent tree (parent, + // depth, who is alive) is the runtime's. The child starts were placed by the + // registry, so their bindings carry the lineage's own resolution. + const expectedBindings: readonly BindingStatus[] = [ { - id: `session:${sessionId}`, - kind: 'root', - provenance: { id: 'native', worktreeRoot: 'native' }, - status: 'active', + actorId: sessionId, + provenance: { actorId: 'native', worktreeRoot: 'native' }, worktreeRoot: fixture.repoRoot, }, { - id: 'agent-a', - kind: 'child', - parentSessionId: sessionId, - provenance: { - id: 'native', - parentSessionId: 'native', - worktreeRoot: 'native', - }, - status: 'active', + actorId: 'agent-a', + provenance: { actorId: 'registry', worktreeRoot: 'native' }, worktreeRoot: fixture.worktreeA, }, { - id: 'agent-b', - kind: 'child', - parentSessionId: sessionId, - provenance: { - id: 'native', - parentSessionId: 'native', - worktreeRoot: 'native', - }, - status: 'active', + actorId: 'agent-b', + provenance: { actorId: 'registry', worktreeRoot: 'native' }, worktreeRoot: fixture.worktreeB, }, ]; const beforeRestart = await callStatus(liveSession.client); expect(beforeRestart).toMatchObject({ activeActivities: 1, - actors: expectedActors, + agents: { state: 'unavailable' }, + bindings: expectedBindings, refusals: 0, state: 'available', }); @@ -416,9 +494,31 @@ it('proves worktree proximity journeys across real processes and linked worktree expect(afterRestart).toEqual(beforeRestart); expect(afterRestart).toMatchObject({ activeActivities: 1, - actors: expectedActors, + bindings: expectedBindings, refusals: 0, state: 'available', }); + // The lineage journal is durable beside the intent state: the restarted + // server still lists both children under the root. + const rootAfterRestart = await callStatusAsRoot(liveSession.client, fixture.repoRoot, 'status-2', hookEnvironment, transcriptPath); + expect(rootAfterRestart.agents.state === 'available' ? rootAfterRestart.agents.children.map((peer) => peer.conversation) : []).toEqual(['agent-a', 'agent-b']); + + // agent-b stops: the registry drops it from the tree, and the route releases + // its worktree binding and the intent it still held on src/shared.ts. + await runHook(fixture.hooks.agentStop, fixture.worktreeB, { + agent_id: 'agent-b', + agent_transcript_path: join(fixture.repoRoot, 'agent-b.jsonl'), + agent_type: 'implementation', + cwd: fixture.worktreeB, + hook_event_name: 'SubagentStop', + last_assistant_message: 'done', + session_id: sessionId, + stop_hook_active: false, + transcript_path: transcriptPath, + }, hookEnvironment); + const afterStop = await callStatusAsRoot(liveSession.client, fixture.repoRoot, 'status-3', hookEnvironment, transcriptPath); + expect(afterStop.agents.state === 'available' ? afterStop.agents.children.map((peer) => peer.conversation) : []).toEqual(['agent-a']); + expect(afterStop.bindings.map((binding) => binding.actorId)).toEqual([sessionId, 'agent-a']); + expect(afterStop.activities.filter((activity) => activity.paths.length > 0)).toEqual([]); expect(liveSession.diagnostics()).not.toContain('"jsonrpc"'); }); diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index 47e8227bb..6c6c1610f 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -109,6 +109,49 @@ export interface AgentLineageSubagent { */ export type AgentLineageResolution = 'native' | 'registry' | 'confirmed' | 'transcript' | 'inferred'; +/** + * One other live conversation in the registry's tree (#457). Every field is + * what the registry recorded when the host said the conversation started — + * nothing is derived from the current request — and `resolution` is the trust + * level of that node's own `parent`/`depth` placement, judged exactly as it + * would be on a request the node itself made. + */ +export interface AgentLineagePeer { + readonly conversation: string; + /** Root is depth 0; each subagent level adds one. */ + readonly depth: number; + /** Absent at a root. */ + readonly parent?: string; + readonly resolution: AgentLineageResolution; + /** When the registry saw the conversation start (the hook's `observedAt`). */ + readonly startedAt: string; + readonly subagent?: AgentLineageSubagent; +} + +/** + * The live tree around this request, as the same registry that placed the + * request holds it, scoped to what the conversation may see: everything alive + * under its own root, and the other live roots beside it. Stopped nodes are + * never listed; a conversation the registry could not place has no tree. + */ +export interface AgentLineageTree { + /** Live conversations whose `parent` is this conversation, oldest first. */ + readonly children: readonly AgentLineagePeer[]; + /** + * Other live depth-0 conversations the registry holds — on Cursor, only + * those seen in the same `workspace_roots` — oldest first. Never includes + * this conversation's own root. + */ + readonly roots: readonly AgentLineagePeer[]; + /** + * Every other live conversation under the same root, at any depth, oldest + * first: the root itself when this conversation is a subagent, its + * ancestors, same-parent siblings, cousins, and descendants (so `children` + * is a subset). Filter by `parent` for conventional same-parent siblings. + */ + readonly siblings: readonly AgentLineagePeer[]; +} + /** * Where this request sits in the conversation tree (#host-lineage). The shape * is identical on every surface: events, generated MCP tools, routed CLI, and @@ -126,6 +169,13 @@ export interface AgentLineage { readonly resolution: AgentLineageResolution; readonly root: string; readonly subagent?: AgentLineageSubagent; + /** + * The live tree around this conversation (#457), present when the warm + * runtime's registry placed it; absent for lineages a payload proved on its + * own (a standalone hook, a Codex `_meta` the registry never saw start) — + * the axis then still answers "who am I" but not "who else is here". + */ + readonly tree?: AgentLineageTree; } export interface AgentFilesystemAuthority { diff --git a/packages/rsc-runtime/src/lineage/registry.ts b/packages/rsc-runtime/src/lineage/registry.ts index b5a78e4a2..b9b39dad7 100644 --- a/packages/rsc-runtime/src/lineage/registry.ts +++ b/packages/rsc-runtime/src/lineage/registry.ts @@ -4,7 +4,10 @@ import { available, unavailable, type AgentLineage, + type AgentLineagePeer, type AgentLineageResolution, + type AgentLineageSubagent, + type AgentLineageTree, type Observed, } from '../agent-request.js'; import { lineageCarrier, type LineageHost } from '../lineage-native.js'; @@ -222,14 +225,8 @@ const cursorWorkspace = (native: Readonly>): string | un const sameWorkspace = (left: string | undefined, right: string | undefined): boolean => left === undefined || right === undefined || left === right; -const lineageOf = (node: LineageNode, generation: string | undefined, resolution: AgentLineageResolution): AgentLineage => Object.freeze({ - conversation: node.id, - depth: node.depth, - ...(generation === undefined ? {} : { generation }), - ...(node.parent === undefined ? {} : { parent: node.parent }), - resolution, - root: node.root, - ...(node.depth === 0 +const subagentOf = (node: LineageNode): { readonly subagent: AgentLineageSubagent } | Record => + node.depth === 0 ? {} : { subagent: Object.freeze({ @@ -238,9 +235,37 @@ const lineageOf = (node: LineageNode, generation: string | undefined, resolution ...(node.toolCallId === undefined ? {} : { toolCallId: node.toolCallId }), ...(node.type === undefined ? {} : { type: node.type }), }), - }), + }; + +const lineageOf = ( + node: LineageNode, + generation: string | undefined, + resolution: AgentLineageResolution, + tree: AgentLineageTree | undefined, +): AgentLineage => Object.freeze({ + conversation: node.id, + depth: node.depth, + ...(generation === undefined ? {} : { generation }), + ...(node.parent === undefined ? {} : { parent: node.parent }), + resolution, + root: node.root, + ...subagentOf(node), + ...(tree === undefined ? {} : { tree }), }); +const peerOf = (node: LineageNode, resolution: AgentLineageResolution): AgentLineagePeer => Object.freeze({ + conversation: node.id, + depth: node.depth, + ...(node.parent === undefined ? {} : { parent: node.parent }), + resolution, + startedAt: node.startedAt, + ...subagentOf(node), +}); + +/** Oldest first, then by id, so two nodes started in the same tick order the same way on every read. */ +const byStart = (left: LineageNode, right: LineageNode): number => + left.startedAt.localeCompare(right.startedAt) || left.id.localeCompare(right.id); + /** * Deterministic journal keys for one observation: the caller's key, the event * name, and a digest of the canonical payload. A duplicate delivery produces @@ -552,6 +577,44 @@ export const createAgentLineageRegistry = ( await dispatch('nodeStarted', rootNode(conversation, node.generation, node.startedAt, node.workspace), keys); }; + /** + * How much of a node's `parent`/`root`/`depth` the host vouched for: a root + * names itself (`native`, except a Cursor root while a child is still + * pending, when the binding it may yet receive keeps it at `fallback`); a + * Codex thread placed from its own rollout is `transcript`; otherwise the + * registry's own match, upgraded to `confirmed` once the host named every + * edge up to the root. + */ + const nodeResolution = (node: LineageNode, host: LineageHost, fallback: AgentLineageResolution): AgentLineageResolution => + node.depth === 0 && (host !== 'cursor' || state.pendingChildren.length === 0) + ? 'native' + : node.placement === 'transcript' ? 'transcript' : registryResolution(node, fallback); + + /** The registry's default trust for a node it placed itself on this host. */ + const placementFallback = (host: LineageHost): AgentLineageResolution => (host === 'cursor' ? 'inferred' : 'registry'); + + /** + * The live tree as `node` may see it (#457): every other live node under + * its root, its own live children, and the other live roots — on Cursor + * only those seen in the same workspace, the same rule that scopes child + * binding. Read from the registry's nodes alone; nothing is fabricated for + * a node the registry never saw start, and stopped nodes are not listed. + */ + const treeFor = (node: LineageNode, host: LineageHost): AgentLineageTree => { + const fallback = placementFallback(host); + const live = Object.values(state.nodes).filter((candidate) => candidate.stoppedAt === undefined && candidate.id !== node.id).sort(byStart); + const peer = (candidate: LineageNode): AgentLineagePeer => peerOf(candidate, nodeResolution(candidate, host, fallback)); + const ownRoot = nodeFor(node.root); + return Object.freeze({ + children: Object.freeze(live.filter((candidate) => candidate.parent === node.id).map(peer)), + roots: Object.freeze(live + .filter((candidate) => candidate.depth === 0 && candidate.id !== node.root + && (host !== 'cursor' || sameWorkspace(candidate.workspace, ownRoot?.workspace ?? node.workspace))) + .map(peer)), + siblings: Object.freeze(live.filter((candidate) => candidate.root === node.root).map(peer)), + }); + }; + const resolve = ( host: LineageHost, native: Readonly>, @@ -561,10 +624,8 @@ export const createAgentLineageRegistry = ( if (carrier.conversation === undefined) return unavailable('id-not-resolvable'); const node = nodeFor(carrier.conversation); if (node === undefined) return unavailable('id-not-resolvable'); - const resolution: AgentLineageResolution = node.depth === 0 && (host !== 'cursor' || state.pendingChildren.length === 0) - ? 'native' - : node.placement === 'transcript' ? 'transcript' : registryResolution(node, fallback); - return available(lineageOf(node, carrier.generation, resolution), resolution === 'native' ? 'native' : 'derived'); + const resolution = nodeResolution(node, host, fallback); + return available(lineageOf(node, carrier.generation, resolution, treeFor(node, host)), resolution === 'native' ? 'native' : 'derived'); }; const observeStart = async (observation: LineageObservation, observedAt: string, keys: JournalKeys): Promise => { @@ -866,6 +927,9 @@ export const createAgentLineageRegistry = ( ? (conversation === root ? 0 : undefined) : parentDepth === undefined ? undefined : parentDepth + 1); if (depth === undefined) return unavailable('id-not-resolvable'); + // The tree is the registry's, so it exists only for a thread the + // registry saw start; `_meta` alone proves the caller's own chain. + const tree = known === undefined ? undefined : treeFor(known, 'codex'); const value: AgentLineage = { conversation, depth, @@ -876,6 +940,7 @@ export const createAgentLineageRegistry = ( ...(parent === undefined ? {} : { subagent: { id: conversation, ...(subagentKind === undefined ? {} : { type: subagentKind }) } }), + ...(tree === undefined ? {} : { tree }), }; return available(value, 'native'); } @@ -913,7 +978,7 @@ export const createAgentLineageRegistry = ( const node = nodeFor(call.conversation); if (node === undefined) return unavailable('id-not-resolvable'); const resolution: AgentLineageResolution = claudeToolUseId !== undefined ? registryResolution(node, 'registry') : 'inferred'; - return available(lineageOf(node, call.generation, resolution), 'derived'); + return available(lineageOf(node, call.generation, resolution, treeFor(node, host)), 'derived'); }, snapshot() { diff --git a/packages/rsc-runtime/src/plugin.ts b/packages/rsc-runtime/src/plugin.ts index b52e9247c..94d56b91a 100644 --- a/packages/rsc-runtime/src/plugin.ts +++ b/packages/rsc-runtime/src/plugin.ts @@ -19,8 +19,10 @@ export type { AgentInvocationInput, AgentInvocationKind, AgentLineage, + AgentLineagePeer, AgentLineageResolution, AgentLineageSubagent, + AgentLineageTree, AgentNetworkAuthority, AgentProcessLifetime, AgentProgressReporter, diff --git a/packages/rsc-runtime/tests/lineage-codex-rollout.test.ts b/packages/rsc-runtime/tests/lineage-codex-rollout.test.ts index 058bf7f10..5a824bc9f 100644 --- a/packages/rsc-runtime/tests/lineage-codex-rollout.test.ts +++ b/packages/rsc-runtime/tests/lineage-codex-rollout.test.ts @@ -145,6 +145,8 @@ describe('lineage registry places Codex threads from their own rollout (#423)', resolution: 'transcript', root: ROOT, subagent: { id: SUBAGENT, toolCallId: SPAWN_CALLS.subagent, type: 'default' }, + // The live tree around the new thread (#457): only the root is alive so far. + tree: { children: [], roots: [], siblings: [{ conversation: ROOT, depth: 0, resolution: 'native', startedAt: expect.any(String) }] }, }); expect(value(nestedStart!.lineage)).toMatchObject({ conversation: NESTED, @@ -243,6 +245,8 @@ describe('lineage registry places Codex threads from their own rollout (#423)', resolution: 'transcript', root: ROOT, subagent: { id: NESTED, type: 'default' }, + // The tree lists what the registry holds: the root it materialized, not the parent thread it only named. + tree: { children: [], roots: [], siblings: [{ conversation: ROOT, depth: 0, resolution: 'native', startedAt: expect.any(String) }] }, }); // The root node was materialized from the payload's session_id; the parent thread itself is named, not fabricated. expect(registry.snapshot().nodes[ROOT]).toMatchObject({ depth: 0 }); diff --git a/packages/rsc-runtime/tests/lineage-registry.test.ts b/packages/rsc-runtime/tests/lineage-registry.test.ts index b94052f29..30f458161 100644 --- a/packages/rsc-runtime/tests/lineage-registry.test.ts +++ b/packages/rsc-runtime/tests/lineage-registry.test.ts @@ -1354,3 +1354,191 @@ describe('lineage registry Claude spawn confirmation from the Agent PostToolUse expect(registry.snapshot().unplacedStarts).toEqual([]); }); }); + +describe('lineage tree: siblings, children and live roots on the request (#457)', () => { + type Replayed = Awaited>; + /** The lineage the fixture row (1-based) resolved to; every row asserted here is an available one. */ + const atRow = (lineages: Replayed, row: number) => { + const entry = lineages.find((candidate) => candidate.index === row); + expect(entry, `row ${String(row)} was replayed`).toBeDefined(); + return value(entry!.lineage); + }; + const tree = (lineages: Replayed, row: number) => { + const lineage = atRow(lineages, row); + expect(lineage.tree, `row ${String(row)} carries a tree`).toBeDefined(); + return lineage.tree!; + }; + const ids = (peers: readonly { readonly conversation: string }[]) => peers.map((peer) => peer.conversation); + + it('Claude 2.1.259 orchestration: every hook sees the live nodes under its root as the registry holds them at that moment', async () => { + const registry = createAgentLineageRegistry(); + const records = fixture('claude-2.1.259-orchestration.ndjson'); + const lineages = await replay('claude', records, registry); + const root = records[0]!.event!.native['session_id'] as string; + const [explore, parallel, sequential, nested] = records + .filter((record) => record.event?.canonical.event === 'agent/start') + .map((record) => record.event!.native['agent_id'] as string); + const startedAt = (agentId: string) => records + .find((record) => record.event?.canonical.event === 'agent/start' && record.event.native['agent_id'] === agentId)! + .event!.canonical.observedAt; + + // Row → [siblings, children]: siblings are every other live node under the root (the root + // itself included for a subagent); children only the direct ones. No second root exists. + const expected: readonly [number, readonly string[], readonly string[]][] = [ + [1, [], []], + [14, [root], []], + [16, [root, explore], []], + [18, [explore, parallel], [explore, parallel]], + [22, [root, parallel], []], + [29, [root, parallel], []], + [42, [root, explore], []], + [58, [parallel], [parallel]], + [62, [], []], + [65, [root], []], + [82, [root, sequential], []], + [91, [root, sequential], []], + [99, [root], []], + [101, [], []], + [104, [], []], + ]; + for (const [row, siblings, children] of expected) { + const view = tree(lineages, row); + expect(ids(view.siblings), `row ${String(row)} siblings`).toEqual(siblings); + expect(ids(view.children), `row ${String(row)} children`).toEqual(children); + expect(view.roots, `row ${String(row)} roots`).toEqual([]); + // A node never lists itself. + expect(ids(view.siblings)).not.toContain(atRow(lineages, row).conversation); + } + + // Each peer carries the registry's facts about that node and its own placement trust: + // the root is `native`; the background pair is `confirmed` from the moment the root's + // Agent PostToolUse named it; the foreground sequential agent stays `registry` while alive. + const rootPeer = tree(lineages, 14).siblings[0]!; + expect(rootPeer).toEqual({ conversation: root, depth: 0, resolution: 'native', startedAt: records[0]!.event!.canonical.observedAt }); + expect(tree(lineages, 16).siblings[1]).toEqual({ + conversation: explore, + depth: 1, + parent: root, + resolution: 'confirmed', + startedAt: startedAt(explore), + subagent: { id: explore, toolCallId: records[11]!.event!.native['tool_use_id'], type: 'Explore' }, + }); + expect(tree(lineages, 22).siblings[1]).toMatchObject({ conversation: parallel, depth: 1, parent: root, resolution: 'confirmed', startedAt: startedAt(parallel) }); + expect(tree(lineages, 82).siblings[1]).toMatchObject({ conversation: sequential, depth: 1, parent: root, resolution: 'registry', startedAt: startedAt(sequential) }); + // The nested child, seen from its parent's next hook while both were alive, sits at depth 2 under it. + const nestedFromParent = lineages.filter((entry) => entry.native?.['agent_id'] === sequential && entry.index > 82 && entry.index < 98); + expect(nestedFromParent).toEqual([]); + expect(tree(lineages, 91).siblings.map((peer) => peer.depth)).toEqual([0, 1]); + expect(atRow(lineages, 91)).toMatchObject({ conversation: nested, depth: 2, parent: sequential }); + // A peer's resolution is what that node's own request resolved to at the same moment. + expect(tree(lineages, 20).siblings.find((peer) => peer.conversation === explore)?.resolution).toBe(atRow(lineages, 22).resolution); + expect(tree(lineages, 83).siblings.find((peer) => peer.conversation === sequential)?.resolution).toBe(atRow(lineages, 81).resolution); + }); + + it('Codex 0.147.0: the tree rides both hook resolutions and _meta-resolved MCP calls, and only for threads the registry saw start', async () => { + const registry = createAgentLineageRegistry(); + const lineages = await replay('codex', fixture('codex-0.147.0.ndjson'), registry); + const root = '01a06660-110e-7290-8d1c-8ef1b2b68fc2'; + const subagent = '01a06660-8faf-7122-80af-24ba2da81ad7'; + const nested = '01a06661-100a-7ad3-a0f5-b0e6ffdb4b11'; + + expect(tree(lineages, 13)).toEqual({ children: [], roots: [], siblings: [] }); + expect(ids(tree(lineages, 17).siblings)).toEqual([root]); + expect(ids(tree(lineages, 18).children)).toEqual([subagent]); + expect(ids(tree(lineages, 28).siblings)).toEqual([root, subagent]); + // `_meta` names the caller's own chain natively; the tree is still the registry's view. + expect(atRow(lineages, 24)).toMatchObject({ conversation: subagent, resolution: 'native' }); + expect(ids(tree(lineages, 24).siblings)).toEqual([root]); + expect(ids(tree(lineages, 35).siblings)).toEqual([root, subagent]); + expect(tree(lineages, 35).siblings[1]).toMatchObject({ conversation: subagent, depth: 1, parent: root, resolution: atRow(lineages, 23).resolution, subagent: { id: subagent } }); + expect(ids(tree(lineages, 38).children)).toEqual([]); + expect(ids(tree(lineages, 38).siblings)).toEqual([root]); + expect(tree(lineages, 40)).toEqual({ children: [], roots: [], siblings: [] }); + + // A thread the registry never saw start still resolves its own chain from `_meta` but has no tree to offer. + const cold = await createAgentLineageRegistry().resolveToolCall({ + host: 'codex', + meta: { 'x-codex-turn-metadata': { parent_thread_id: root, session_id: root, thread_id: nested, turn_id: 't' } }, + toolName: 'probe', + }); + expect(value(cold)).toMatchObject({ conversation: nested, depth: 1, parent: root, resolution: 'native' }); + expect(value(cold).tree).toBeUndefined(); + }); + + it('Cursor 3.18.25: a pending child is listed under its subagent id until it speaks, then under its conversation; stops drop it', async () => { + const registry = createAgentLineageRegistry(); + const records = fixture('cursor-3.18.25.ndjson'); + const lineages = await replay('cursor', records, registry); + const root = 'b60ae0c1-2f85-4c4d-b3e5-b512f9b06e4c'; + const child = 'bf617dfd-e03d-4d6b-adef-8f97e7df6b71'; + const firstSpawn = records[35]!.event!.native['subagent_id'] as string; + const thirdSpawn = records[67]!.event!.native['subagent_id'] as string; + const nestedId = (id: string) => id.startsWith('46efda32'); + + // The parent's subagentStart lists the child by the only id the host has given it so far. + expect(tree(lineages, 36).children).toEqual([expect.objectContaining({ conversation: firstSpawn, depth: 1, parent: root, resolution: 'inferred', subagent: expect.objectContaining({ id: firstSpawn, type: 'general-purpose' }) })]); + // Once bound, the child sees the root; the root's next hook lists it under its conversation. + expect(ids(tree(lineages, 37).siblings)).toEqual([root]); + expect(ids(tree(lineages, 49).siblings)).toEqual([root, child]); + expect(tree(lineages, 49).siblings[1]).toMatchObject({ conversation: child, depth: 1, parent: root, resolution: 'inferred' }); + expect(ids(tree(lineages, 53).children)).toEqual([]); + // The nested child, from the first child's stop hook: gone by then (row 56 stopped it). + expect(ids(tree(lineages, 58).siblings)).toEqual([]); + expect(ids(tree(lineages, 69).siblings)).toEqual([root]); + expect(ids(tree(lineages, 87).siblings)).toEqual([]); + for (const row of [36, 37, 49, 58, 69, 87]) expect(tree(lineages, row).roots).toEqual([]); + // Nobody else fires a hook while the nested child is alive (its parent's next hook is the + // stop that retires it, resolved after the stop applied), so only its own rows show it placed. + expect(lineages.filter((entry) => entry.lineage.state === 'available' && nestedId(entry.lineage.value.conversation)).map((entry) => ids(entry.lineage.value.tree!.siblings))) + .toEqual(Array.from({ length: 7 }, () => [root, child])); + expect(lineages.some((entry) => entry.lineage.state === 'available' && ids(entry.lineage.value.tree?.siblings ?? []).some(nestedId))).toBe(false); + }); + + it('lists other live roots, scoped on Cursor to the same workspace, and drops a root that ended', async () => { + const registry = createAgentLineageRegistry(); + const claude = (event: string, key: string, native: Record, observedAt: string) => + registry.observe({ event, host: 'claude', idempotencyKey: key, native, observedAt }); + const startedA = value(await claude('session/start', 'a', { hook_event_name: 'SessionStart', session_id: 'session-a' }, '2026-09-03T00:00:00.000Z')); + expect(startedA.tree).toEqual({ children: [], roots: [], siblings: [] }); + const startedB = value(await claude('session/start', 'b', { hook_event_name: 'SessionStart', session_id: 'session-b' }, '2026-09-03T00:00:01.000Z')); + expect(startedB.tree).toEqual({ children: [], roots: [{ conversation: 'session-a', depth: 0, resolution: 'native', startedAt: '2026-09-03T00:00:00.000Z' }], siblings: [] }); + await claude('tool/before', 'a:spawn', { hook_event_name: 'PreToolUse', session_id: 'session-a', tool_input: { prompt: 'x' }, tool_name: 'Agent', tool_use_id: 'spawn-a' }, '2026-09-03T00:00:02.000Z'); + const child = value(await claude('agent/start', 'a:start', { agent_id: 'child-a', agent_type: 'general-purpose', hook_event_name: 'SubagentStart', session_id: 'session-a' }, '2026-09-03T00:00:03.000Z')); + // A subagent sees its own root's subtree and the other live roots, never the other root's children. + expect(child.tree).toMatchObject({ children: [], roots: [{ conversation: 'session-b' }], siblings: [{ conversation: 'session-a', depth: 0 }] }); + const rootA = value(await claude('prompt/submit', 'a:prompt', { hook_event_name: 'UserPromptSubmit', prompt: 'x', session_id: 'session-a' }, '2026-09-03T00:00:04.000Z')); + expect(rootA.tree).toMatchObject({ children: [{ conversation: 'child-a', resolution: 'registry' }], roots: [{ conversation: 'session-b' }], siblings: [{ conversation: 'child-a' }] }); + const rootB = value(await claude('prompt/submit', 'b:prompt', { hook_event_name: 'UserPromptSubmit', prompt: 'x', session_id: 'session-b' }, '2026-09-03T00:00:05.000Z')); + expect(rootB.tree).toEqual({ children: [], roots: [{ conversation: 'session-a', depth: 0, resolution: 'native', startedAt: '2026-09-03T00:00:00.000Z' }], siblings: [] }); + await claude('session/end', 'b:end', { hook_event_name: 'SessionEnd', reason: 'other', session_id: 'session-b' }, '2026-09-03T00:00:06.000Z'); + const afterEnd = value(await claude('prompt/submit', 'a:prompt-2', { hook_event_name: 'UserPromptSubmit', prompt: 'y', session_id: 'session-a' }, '2026-09-03T00:00:07.000Z')); + expect(afterEnd.tree?.roots).toEqual([]); + + // Two Cursor windows sharing one durable registry: each root sees only roots of its own workspace. + const cursorRegistry = createAgentLineageRegistry(); + const cursor = (key: string, conversation: string, roots: readonly string[], observedAt: string) => cursorRegistry.observe({ + event: 'prompt/submit', + host: 'cursor', + idempotencyKey: key, + native: { conversation_id: conversation, generation_id: `${key}-gen`, hook_event_name: 'beforeSubmitPrompt', prompt: 'x', workspace_roots: roots }, + observedAt, + }); + await cursor('w1', 'window-1', ['/w1'], '2026-09-03T00:00:00.000Z'); + await cursor('w2', 'window-2', ['/w2'], '2026-09-03T00:00:01.000Z'); + const window3 = value(await cursor('w3', 'window-3', ['/w1'], '2026-09-03T00:00:02.000Z')); + expect(ids(window3.tree!.roots)).toEqual(['window-1']); + const window2 = value(await cursor('w2-again', 'window-2', ['/w2'], '2026-09-03T00:00:03.000Z')); + expect(window2.tree!.roots).toEqual([]); + }); + + it('freezes the tree and hands out plain data that survives structured cloning to the Flight worker', async () => { + const registry = createAgentLineageRegistry(); + await registry.observe({ event: 'session/start', host: 'claude', idempotencyKey: 's', native: { hook_event_name: 'SessionStart', session_id: 'root' }, observedAt: '2026-09-03T00:00:00.000Z' }); + await registry.observe({ event: 'tool/before', host: 'claude', idempotencyKey: 'sp', native: { hook_event_name: 'PreToolUse', session_id: 'root', tool_input: {}, tool_name: 'Agent', tool_use_id: 'sp' }, observedAt: '2026-09-03T00:00:01.000Z' }); + const lineage = value(await registry.observe({ event: 'agent/start', host: 'claude', idempotencyKey: 'st', native: { agent_id: 'child', agent_type: 'general-purpose', hook_event_name: 'SubagentStart', session_id: 'root' }, observedAt: '2026-09-03T00:00:02.000Z' })); + expect(Object.isFrozen(lineage.tree)).toBe(true); + expect(Object.isFrozen(lineage.tree!.siblings)).toBe(true); + expect(Object.isFrozen(lineage.tree!.siblings[0])).toBe(true); + expect(structuredClone(lineage)).toEqual(lineage); + }); +}); diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 2bd89c9a6..e5f6a0f5b 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -115,6 +115,27 @@ siblings claimed blind, places a start no spawn window could, and moves a child filed under the wrong parent. There is deliberately no operator or user identity axis: the framework never reads or surfaces who the human behind a host session is. +When the registry placed the request, the lineage also carries `tree` — who else is alive, read +from the same registry and never invented: + +```ts +const { lineage } = await agent(); +if (lineage.state === 'available' && lineage.value.tree !== undefined) { + lineage.value.tree.siblings; // every other live conversation under the same root (any depth, + // the root itself included for a subagent), oldest first + lineage.value.tree.children; // live conversations whose parent is this one + lineage.value.tree.roots; // other live root conversations (Cursor: same workspace_roots) +} +``` + +Each peer is `{ conversation, depth, parent?, startedAt, subagent?, resolution }` — the +registry's facts about that node, with the trust level of *its* placement, judged exactly as on +a request that node made itself. Stopped conversations are not listed; a Cursor child whose +conversation has not spoken yet is listed under its `subagent_id`. The tree is absent when the +registry did not place the request (a standalone hook, or a Codex `_meta` naming a thread it +never saw start): the axis then still says who you are, not who else is here. Route-unit tests +inject it through the same `context.lineage` seam as the rest of the axis. + ## Streaming and progress A route streams by rendering React `Suspense`: the shell goes out first with the fallback in diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 027044f3c..5136e89ae 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -105,6 +105,24 @@ start 匹配到最新一个未被认领的 spawn 调用)、`confirmed`(宿 窗口无法安放的 start,并把窗口挂错父节点的子代理挪到正确的父节点下。框架刻意不提供操作者或用户身份轴:它永远不会读取或暴露宿主 会话背后的人是谁。 +当注册表安放了这次请求时,lineage 还会带有 `tree`——还有谁在线,读自同一个注册表,绝不凭空编造: + +```ts +const { lineage } = await agent(); +if (lineage.state === 'available' && lineage.value.tree !== undefined) { + lineage.value.tree.siblings; // 同一根会话之下所有其他在线会话(任意深度, + // 对子代理而言包含根会话本身),按启动时间从早到晚 + lineage.value.tree.children; // 以本会话为父节点的在线会话 + lineage.value.tree.roots; // 其他在线的根会话(Cursor:同一 workspace_roots) +} +``` + +每个对等节点是 `{ conversation, depth, parent?, startedAt, subagent?, resolution }`——注册表关于该 +节点的事实,以及*它自己*安放的可信级别,与该节点自己发出请求时得到的判断完全一致。已停止的会话不会 +列出;尚未开口的 Cursor 子会话以其 `subagent_id` 列出。注册表没有安放这次请求时(独立钩子进程,或 +Codex `_meta` 指向一个注册表从未见过其启动的线程),`tree` 不存在:此时这一轴仍然回答"我是谁",但不 +回答"还有谁在这里"。路由单元测试通过与该轴其余部分相同的 `context.lineage` 接缝注入它。 + ## 流式输出与进度 路由通过渲染 React `Suspense` 实现流式输出:外壳(shell)先带着回退内容发出,之后每个解析完成的 From 6c532f2fa8605a869e737fc721b35134c25ee24e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:14:45 +0000 Subject: [PATCH 2/3] chore: reference #544 in the changeset --- .changeset/457-lineage-tree.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/457-lineage-tree.md b/.changeset/457-lineage-tree.md index 5f1447203..4de3446a3 100644 --- a/.changeset/457-lineage-tree.md +++ b/.changeset/457-lineage-tree.md @@ -2,4 +2,4 @@ "@agent-bundle/runtime": patch --- -Expose the live agent tree to routes: `(await agent()).lineage.value.tree` — `{ siblings, children, roots }` of `AgentLineagePeer` (`{ conversation, depth, parent?, startedAt, subagent?, resolution }`) — lists every other live conversation under the request's root (any depth, the root itself included for a subagent), the request's live children, and the other live root conversations (on Cursor, only those seen in the same `workspace_roots`), read at resolve time from the same lineage registry that placed the request on every surface it feeds (event routes, generated MCP tool calls correlated through a hook window or a Codex `_meta`). Nothing is invented: stopped conversations are not listed, each peer carries the registry's own `resolution` for its placement, and the tree is absent when the registry did not place the request (a standalone hook, or a `_meta` naming a thread the registry never saw start). New exported types `AgentLineageTree` and `AgentLineagePeer`; `AgentLineage.tree` is optional, so existing readers and injected `context.lineage` fixtures are unchanged. (#457) +Expose the live agent tree to routes: `(await agent()).lineage.value.tree` — `{ siblings, children, roots }` of `AgentLineagePeer` (`{ conversation, depth, parent?, startedAt, subagent?, resolution }`) — lists every other live conversation under the request's root (any depth, the root itself included for a subagent), the request's live children, and the other live root conversations (on Cursor, only those seen in the same `workspace_roots`), read at resolve time from the same lineage registry that placed the request on every surface it feeds (event routes, generated MCP tool calls correlated through a hook window or a Codex `_meta`). Nothing is invented: stopped conversations are not listed, each peer carries the registry's own `resolution` for its placement, and the tree is absent when the registry did not place the request (a standalone hook, or a `_meta` naming a thread the registry never saw start). New exported types `AgentLineageTree` and `AgentLineagePeer`; `AgentLineage.tree` is optional, so existing readers and injected `context.lineage` fixtures are unchanged. (#544) From 7332e374d466b376b3a3b4b60bfd68c827a239e5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:32:18 +0000 Subject: [PATCH 3/3] test(projection): pin the lineage tree on in-memory MCP tool calls --- packages/agent-bundle/tests/projection/mcp-lineage.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/agent-bundle/tests/projection/mcp-lineage.test.ts b/packages/agent-bundle/tests/projection/mcp-lineage.test.ts index 2ae3dcf54..f27aefe4b 100644 --- a/packages/agent-bundle/tests/projection/mcp-lineage.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-lineage.test.ts @@ -53,7 +53,8 @@ describe('generated MCP tool calls resolve request.lineage through the runtime r expect(await callContext(registry, { 'claudecode/toolUseId': 'toolu_1' })).toEqual({ source: 'derived', state: 'available', - value: { conversation: root, depth: 0, resolution: 'registry', root }, + // The live tree rides along (#457): the root is alone so far. + value: { conversation: root, depth: 0, resolution: 'registry', root, tree: { children: [], roots: [], siblings: [] } }, }); await observe(registry, 'tool/after', { @@ -100,6 +101,8 @@ describe('generated MCP tool calls resolve request.lineage through the runtime r resolution: 'inferred', root, subagent: { id: child, toolCallId: 'toolu_spawn', type: 'general-purpose' }, + // The child sees its root as the one other live node under the same root (#457). + tree: { children: [], roots: [], siblings: [{ conversation: root, depth: 0, resolution: 'native', startedAt: expect.any(String) }] }, }, }); expect(await callContext(registry, { 'claudecode/toolUseId': 'toolu_child_call' })).toMatchObject({