From 449a8e4ea468e31cad20de855821eae0ccfdd7e5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 01:27:37 +0000 Subject: [PATCH 1/2] wip: worktree-proximity example snapshot (worker died mid-edit) --- examples/worktree-proximity/README.md | 112 +++++++ .../worktree-proximity/agent-bundle.config.ts | 11 + examples/worktree-proximity/package.json | 33 ++ .../rstest.route-unit.config.ts | 4 + examples/worktree-proximity/src/api.ts | 36 ++ .../worktree-proximity/src/coordination.ts | 102 ++++++ .../src/domain/proximity.ts | 88 +++++ .../worktree-proximity/src/event-support.ts | 140 ++++++++ .../src/events/agent/start.tsx | 93 ++++++ .../src/events/session/start.tsx | 65 ++++ .../worktree-proximity/src/events/stop.tsx | 56 ++++ .../src/events/tool/after.tsx | 55 ++++ .../src/events/tool/before.tsx | 100 ++++++ .../src/mcp/coordinator/tools/status.tsx | 90 +++++ .../src/providers/agent-topology.ts | 59 ++++ .../src/providers/git-worktree.ts | 78 +++++ examples/worktree-proximity/src/state.ts | 166 ++++++++++ .../tests/proximity.test.ts | 95 ++++++ .../tests/route-unit/routes.test.ts | 311 ++++++++++++++++++ examples/worktree-proximity/tsconfig.json | 13 + pnpm-lock.yaml | 202 ++---------- 21 files changed, 1730 insertions(+), 179 deletions(-) create mode 100644 examples/worktree-proximity/README.md create mode 100644 examples/worktree-proximity/agent-bundle.config.ts create mode 100644 examples/worktree-proximity/package.json create mode 100644 examples/worktree-proximity/rstest.route-unit.config.ts create mode 100644 examples/worktree-proximity/src/api.ts create mode 100644 examples/worktree-proximity/src/coordination.ts create mode 100644 examples/worktree-proximity/src/domain/proximity.ts create mode 100644 examples/worktree-proximity/src/event-support.ts create mode 100644 examples/worktree-proximity/src/events/agent/start.tsx create mode 100644 examples/worktree-proximity/src/events/session/start.tsx create mode 100644 examples/worktree-proximity/src/events/stop.tsx create mode 100644 examples/worktree-proximity/src/events/tool/after.tsx create mode 100644 examples/worktree-proximity/src/events/tool/before.tsx create mode 100644 examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx create mode 100644 examples/worktree-proximity/src/providers/agent-topology.ts create mode 100644 examples/worktree-proximity/src/providers/git-worktree.ts create mode 100644 examples/worktree-proximity/src/state.ts create mode 100644 examples/worktree-proximity/tests/proximity.test.ts create mode 100644 examples/worktree-proximity/tests/route-unit/routes.test.ts create mode 100644 examples/worktree-proximity/tsconfig.json diff --git a/examples/worktree-proximity/README.md b/examples/worktree-proximity/README.md new file mode 100644 index 000000000..8365f07b5 --- /dev/null +++ b/examples/worktree-proximity/README.md @@ -0,0 +1,112 @@ +# 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. + +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. +3. `tool/before` records current path and dependency intent. +4. The pure proximity domain compares active intents from different worktrees. +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. + +The demonstration dependency convention is a `deps:` string in tool input: + +```json +{ "file_path": "src/shared.ts", "deps": "deps:react,zod" } +``` + +`Write`, `Edit`, and `Read` contribute `file_path`. Dependency names are +trimmed and compared case-insensitively. Paths are normalized to +repository-relative slash-separated paths by the domain module. + +## Architecture + +The application has four planes: + +- **Providers** — `git-worktree` derives repository, branch, commit, common + Git directory, and linked-worktree identity without throwing for expected + degradation. `agent-topology` exposes a read-only durable snapshot. +- **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 a SQLite state root. Each request opens, + uses, and closes its stores; SQLite supplies cross-process durability and + idempotency without a daemon. +- **Domain** — `src/domain/proximity.ts` contains all collision decisions and + performs no I/O. + +`WORKTREE_PROXIMITY_STATE_DIR` overrides storage for tests and explicit +deployments. Otherwise state lives at +`/agent-bundle-proximity/`, so linked worktrees share one +durable topology and notice ledger. + +`worktree()` in `src/api.ts` is the issue-mandated custom Promise API over the +provider value. A `useWorktree()` React-hook variant is recorded unavailable: +main exposes no client-hook contract for provider values. + +## Actor identity and provenance + +Every identity claim records whether it came from a native envelope or was +derived: + +- `session/start` observes `session:` as the root actor. +- `agent/start` requires native `agent_id` and `session_id`, records the child, + and records its parent session provenance as native. +- Tool envelopes contain no `agent_id`. A tool event first resolves an active + actor already bound to the event worktree. Without an earlier binding it + uses the explicit derived identity `worktree:` and records that + provenance; it never upgrades the derived identity to native. +- `agent/start` without native identity records an `edgeRefused` event and + renders that parent identity is unavailable. It refuses to fabricate a + topology edge. + +Unsupported worktree, actor, parent, state, and delivery conditions are +rendered as unavailable instead of being replaced with invented evidence. + +## Framework primitive wiring + +The topology and notice operations are custom APIs composed from public +framework primitives. Generated bundles do not yet mount `(await +agent()).state` or `(await agent()).notices` (issue #233). The application +probes those reserved request handles first and uses them when available, +then falls back to opening the SQLite driver and notice ledger for the current +request. This is application wiring, not a private framework import, and it +can disappear naturally when #233 lands. + +The local notice authorizer admits this repository-scoped demonstration's +actor-addressed publications and deliveries. Recipient matching uses only the +actor axis, so it does not accidentally require a matching session or +worktree axis. + +## Evidence boundary + +The deterministic suite is artifact/contract integration evidence, NOT +commercial-host dispatch proof. Route-unit tests render compiled route +modules through the framework request and document contracts in-process. They +do not prove that Claude, Codex, or another commercial host invokes a hook, +preserves its envelope, or displays projected context in production. + +The later real-child-process journey suite is responsible for process-level +restart and dispatch evidence. The version-1 state design is restart durable, +but this slice makes no claim that the later journey suite has run. + +## External-driver boundary + +Version 1 connects NO external adapter and claims none. A real external +adapter must pass the framework state-driver conformance suite before any +“integrated” claim. SQLite is the only durable driver used by this example. diff --git a/examples/worktree-proximity/agent-bundle.config.ts b/examples/worktree-proximity/agent-bundle.config.ts new file mode 100644 index 000000000..757e82625 --- /dev/null +++ b/examples/worktree-proximity/agent-bundle.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'agent-bundle/config'; + +export default defineConfig({ + plugin: { + description: 'Coordinate related agents across linked worktrees without a daemon.', + name: 'worktree-proximity', + version: '1.0.0', + }, + runtime: { node: '22.19.0' }, + targets: ['claude', 'codex'], +}); diff --git a/examples/worktree-proximity/package.json b/examples/worktree-proximity/package.json new file mode 100644 index 000000000..20067a134 --- /dev/null +++ b/examples/worktree-proximity/package.json @@ -0,0 +1,33 @@ +{ + "name": "@agent-bundle-example/worktree-proximity", + "version": "1.0.0", + "private": true, + "description": "An advanced worktree-proximity coordination reference for agent-bundle.", + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "files": [ + "artifact", + "README.md" + ], + "scripts": { + "build": "agent-bundle build --output artifact", + "check": "pnpm validate && pnpm build && pnpm typecheck && pnpm test && pnpm test:routes", + "dev": "agent-bundle dev", + "test": "rstest tests --exclude 'tests/route-unit/**'", + "test:routes": "rstest --config rstest.route-unit.config.ts", + "typecheck": "tsc -p tsconfig.json --noEmit", + "validate": "agent-bundle validate" + }, + "dependencies": { + "@agent-bundle/runtime": "workspace:*", + "react": "19.2.8", + "zod": "4.4.3" + }, + "devDependencies": { + "@rstest/core": "0.11.10", + "@types/react": "19.2.18", + "agent-bundle": "workspace:*" + } +} diff --git a/examples/worktree-proximity/rstest.route-unit.config.ts b/examples/worktree-proximity/rstest.route-unit.config.ts new file mode 100644 index 000000000..d4adc77ee --- /dev/null +++ b/examples/worktree-proximity/rstest.route-unit.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from '@rstest/core'; +import { agentBundleRstest } from 'agent-bundle/rstest'; + +export default defineConfig(await agentBundleRstest()); diff --git a/examples/worktree-proximity/src/api.ts b/examples/worktree-proximity/src/api.ts new file mode 100644 index 000000000..3da19beb9 --- /dev/null +++ b/examples/worktree-proximity/src/api.ts @@ -0,0 +1,36 @@ +import { agent } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +export const WorktreeProviderValueSchema = z.discriminatedUnion('state', [ + z + .object({ + branch: z.string().min(1), + commonDir: z.string().min(1), + head: z.string().min(1), + isLinkedWorktree: z.boolean(), + root: z.string().min(1), + source: z.enum(['native-cwd', 'process-cwd']), + state: z.literal('available'), + }) + .strict(), + z + .object({ + reason: z.string().min(1), + state: z.literal('unavailable'), + }) + .strict(), +]); + +export type WorktreeProviderValue = z.output; +export type AvailableWorktree = Extract; + +export const worktree = async (): Promise => { + const candidate = (await agent()).providers.gitWorktree; + const parsed = WorktreeProviderValueSchema.safeParse(candidate); + return parsed.success + ? parsed.data + : { + reason: 'The git-worktree provider did not expose a valid worktree identity.', + state: 'unavailable', + }; +}; diff --git a/examples/worktree-proximity/src/coordination.ts b/examples/worktree-proximity/src/coordination.ts new file mode 100644 index 000000000..6e6d531f9 --- /dev/null +++ b/examples/worktree-proximity/src/coordination.ts @@ -0,0 +1,102 @@ +import { resolve, join } from 'node:path'; + +import { + agent, + available, + type AgentStateHandle, + type ObservedSource, +} from '@agent-bundle/runtime'; +import { + agentNoticeStateDefinition, + createAgentNoticeLedger, + type AgentNoticeLedgerSnapshot, + type AgentNoticesHandle, +} from '@agent-bundle/runtime/notices'; +import type { AgentStateStore } from '@agent-bundle/runtime/state'; +import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'; + +import type { AvailableWorktree } from './api.js'; +import { + topologyStateDefinition, + type TopologyEvents, + type TopologyState, +} from './state.js'; + +export type TopologyAccess = + Pick, 'dispatch' | 'read'>; + +export const stateRootFor = (worktree: AvailableWorktree): string => { + const configured = process.env.WORKTREE_PROXIMITY_STATE_DIR; + return configured === undefined || configured.trim() === '' + ? join(worktree.commonDir, 'agent-bundle-proximity') + : resolve(configured); +}; + +export const withTopology = async ( + worktree: AvailableWorktree, + operation: (topology: TopologyAccess) => Promise, +): Promise => { + const context = await agent(); + // Generated bundles do not mount these reserved handles yet (#233). Prefer + // the framework handle when present; otherwise compose the same primitives + // here and keep their lifecycle scoped to this application request. + if (context.state !== undefined) { + return operation(context.state as AgentStateHandle); + } + + const driver = createSqliteStateDriver({ root: stateRootFor(worktree) }); + try { + return await operation(await driver.open(topologyStateDefinition)); + } finally { + await driver.close(); + } +}; + +export const withNotices = async ( + worktree: AvailableWorktree, + actorId: string | undefined, + actorSource: ObservedSource, + operation: (notices: AgentNoticesHandle) => Promise, +): Promise => { + const context = await agent(); + if (context.notices !== undefined) { + return operation(context.notices); + } + + const driver = createSqliteStateDriver({ root: stateRootFor(worktree) }); + let lease: Awaited['openRequest']>> | undefined; + try { + const store = await driver.open(agentNoticeStateDefinition()); + const ledger = createAgentNoticeLedger(store, { + authorize: () => ({ state: 'authorized' }), + }); + lease = await ledger.openRequest({ + invocation: context.invocation, + principal: { + actor: actorId === undefined ? context.actor : available({ id: actorId }, actorSource), + host: context.host, + session: context.session, + workspace: context.workspace, + }, + signal: context.signal, + }); + return await operation(lease.handle); + } finally { + lease?.close(); + await driver.close(); + } +}; + +export const readNoticeLedger = async ( + worktree: AvailableWorktree, +): Promise => { + const driver = createSqliteStateDriver({ root: stateRootFor(worktree) }); + try { + const store = await driver.open(agentNoticeStateDefinition()); + return await createAgentNoticeLedger(store, { + authorize: () => ({ state: 'authorized' }), + }).read(); + } finally { + await driver.close(); + } +}; diff --git a/examples/worktree-proximity/src/domain/proximity.ts b/examples/worktree-proximity/src/domain/proximity.ts new file mode 100644 index 000000000..e010ab90d --- /dev/null +++ b/examples/worktree-proximity/src/domain/proximity.ts @@ -0,0 +1,88 @@ +import type { TopologyState } from '../state.js'; + +export interface ProximityIntent { + readonly actorId: string; + readonly dependencies: readonly string[]; + readonly paths: readonly string[]; +} + +export interface ProximityConflict { + readonly actorId: string; + readonly summary: string; +} + +const normalizeSegments = (value: string): string => { + const segments: string[] = []; + for (const segment of value.replaceAll('\\', '/').split('/')) { + if (segment === '' || segment === '.') continue; + if (segment === '..' && segments.length > 0 && segments.at(-1) !== '..') { + segments.pop(); + } else { + segments.push(segment); + } + } + return segments.join('/'); +}; + +const normalizePath = (value: string, worktreeRoot: string): string => { + const path = value.replaceAll('\\', '/'); + const root = worktreeRoot.replaceAll('\\', '/').replace(/\/+$/u, ''); + const relative = path === root + ? '.' + : path.startsWith(`${root}/`) + ? path.slice(root.length + 1) + : path; + return normalizeSegments(relative); +}; + +const normalizeDependency = (value: string): string => value.trim().toLowerCase(); + +export const findProximity = ( + snapshot: TopologyState, + currentWorktree: string, + intent: ProximityIntent, +): 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 conflicts: ProximityConflict[] = []; + + for (const activity of snapshot.activities) { + if (activity.actorId === intent.actorId) continue; + const actor = actors.get(activity.actorId); + if ( + actor === undefined + || actor.status !== 'active' + || actor.worktreeRoot === undefined + || actor.worktreeRoot === currentWorktree + ) { + continue; + } + + const sharedPath = activity.paths + .map((path) => normalizePath(path, actor.worktreeRoot!)) + .find((path) => currentPaths.has(path)); + if (sharedPath !== undefined) { + conflicts.push({ + actorId: actor.id, + summary: + `Worktrees ${currentWorktree} and ${actor.worktreeRoot} both intend to change path ${sharedPath}.`, + }); + } + + const sharedDependency = activity.dependencies + .map(normalizeDependency) + .find((dependency) => currentDependencies.has(dependency)); + if (sharedDependency !== undefined) { + conflicts.push({ + actorId: actor.id, + summary: + `Worktrees ${currentWorktree} and ${actor.worktreeRoot} both depend on ${sharedDependency}.`, + }); + } + } + + return conflicts; +}; diff --git a/examples/worktree-proximity/src/event-support.ts b/examples/worktree-proximity/src/event-support.ts new file mode 100644 index 000000000..d5bfb35d6 --- /dev/null +++ b/examples/worktree-proximity/src/event-support.ts @@ -0,0 +1,140 @@ +import type { + AgentDocumentNode, + AgentNoticeDelivery, + ObservedSource, +} from '@agent-bundle/runtime'; + +import type { AvailableWorktree } from './api.js'; +import type { TopologyAccess } from './coordination.js'; +import type { TopologyState } from './state.js'; + +export interface EventIdentity { + readonly idempotencyKey: string; + readonly observedAt: string; +} + +export interface ExtractedIntent { + readonly dependencies: readonly string[]; + readonly paths: readonly string[]; +} + +export interface ResolvedActor { + readonly id: string; + readonly source: ObservedSource; +} + +export const nativeString = ( + native: Readonly>, + key: string, +): string | undefined => { + const value = native[key]; + return typeof value === 'string' && value.trim() !== '' ? value : undefined; +}; + +const inputRecord = (native: Readonly>): Readonly> => { + const input = native.tool_input; + return input !== null && typeof input === 'object' && !Array.isArray(input) + ? input as Readonly> + : {}; +}; + +const dependenciesFrom = (input: Readonly>): readonly string[] => { + const convention = input.deps; + if (typeof convention !== 'string' || !convention.trim().toLowerCase().startsWith('deps:')) return []; + return convention + .trim() + .slice('deps:'.length) + .split(/[\s,]+/u) + .map((dependency) => dependency.trim()) + .filter((dependency) => dependency !== ''); +}; + +export const extractIntent = ( + native: Readonly>, +): ExtractedIntent => { + const input = inputRecord(native); + const toolName = nativeString(native, 'tool_name'); + const path = input.file_path; + const paths = + (toolName === 'Write' || toolName === 'Edit' || toolName === 'Read') + && typeof path === 'string' + && path.trim() !== '' + ? [path] + : []; + return { + dependencies: dependenciesFrom(input), + paths, + }; +}; + +export const actorForWorktree = async ( + topology: TopologyAccess, + worktree: AvailableWorktree, + canonical: EventIdentity, +): Promise<{ readonly actor: ResolvedActor; readonly snapshot: TopologyState }> => { + const before = await topology.read(); + 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, + ); + if (bound !== undefined) { + return { + actor: { id: bound.id, source: bound.provenance.id }, + snapshot: before.state, + }; + } + + const actor: ResolvedActor = { + 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', { + actorId: actor.id, + provenance: 'derived', + worktreeRoot: worktree.root, + }, { + idempotencyKey: `${canonical.idempotencyKey}:derived-worktree`, + }); + return { actor, snapshot: boundResult.state }; +}; + +const nodeText = (node: AgentDocumentNode): string => { + switch (node.kind) { + case 'result': + return node.children.map(nodeText).filter(Boolean).join('\n'); + case 'context': + case 'markdown': + case 'text': + return node.text; + case 'error': + return `${node.code}: ${node.message}`; + case 'resource': + return `${node.name} (${node.uri})`; + case 'progress': + return node.message ?? ''; + case 'audio': + case 'image': + case 'json': + return ''; + default: { + const unreachable: never = node; + throw new Error(`Unhandled Agent Document node ${String(unreachable)}`); + } + } +}; + +export const deliveryContexts = ( + deliveries: readonly AgentNoticeDelivery[], +): readonly string[] => deliveries.map((delivery) => { + const text = nodeText(delivery.notice.content.root); + return `Directed proximity notice (${delivery.notice.state}, ${delivery.receipt.channel}): ${text}`; +}); diff --git a/examples/worktree-proximity/src/events/agent/start.tsx b/examples/worktree-proximity/src/events/agent/start.tsx new file mode 100644 index 000000000..228b6d8e5 --- /dev/null +++ b/examples/worktree-proximity/src/events/agent/start.tsx @@ -0,0 +1,93 @@ +import { Agent } from '@agent-bundle/runtime'; +import type { AgentEventRouteProps } from 'agent-bundle'; +import React from 'react'; + +import { worktree } from '../../api.js'; +import { withNotices, withTopology } from '../../coordination.js'; +import { deliveryContexts, nativeString } from '../../event-support.js'; + +export const config = { + runtime: 'shared', + targets: ['claude', 'codex'], +}; + +export default async function AgentStart({ + canonical, + native, +}: AgentEventRouteProps) { + const currentWorktree = await worktree(); + if (currentWorktree.state === 'unavailable') { + return ( + + {`Child topology unavailable: ${currentWorktree.reason}`} + + ); + } + + const agentId = nativeString(native, 'agent_id'); + const sessionId = nativeString(native, 'session_id'); + const refusal = agentId === undefined + ? 'agent/start omitted native agent_id; refused to fabricate a topology edge' + : sessionId === undefined + ? 'agent/start omitted native session_id; refused to fabricate a topology edge' + : undefined; + if (refusal !== undefined) { + await withTopology(currentWorktree, async (topology) => { + await topology.dispatch('edgeRefused', { + idempotencyKey: canonical.idempotencyKey, + observedAt: canonical.observedAt, + reason: refusal, + ...(sessionId === undefined ? {} : { sessionId }), + }, { + idempotencyKey: `${canonical.idempotencyKey}:refusal`, + }); + }); + const deliveries = await withNotices( + currentWorktree, + undefined, + 'derived', + async (notices) => notices.read(), + ); + return ( + + {`Parent identity unavailable; ${refusal}.`} + {deliveryContexts(deliveries).map((context) => + {context})} + + ); + } + + await withTopology(currentWorktree, async (topology) => { + await topology.dispatch('actorObserved', { + id: agentId, + kind: 'child', + parentSessionId: sessionId, + provenance: { + id: 'native', + parentSessionId: 'native', + }, + status: 'active', + }, { + idempotencyKey: `${canonical.idempotencyKey}:actor`, + }); + await topology.dispatch('actorBound', { + actorId: agentId, + provenance: 'native', + worktreeRoot: currentWorktree.root, + }, { + idempotencyKey: `${canonical.idempotencyKey}:worktree`, + }); + }); + const deliveries = await withNotices( + currentWorktree, + agentId, + 'native', + async (notices) => notices.read(), + ); + return ( + + {deliveryContexts(deliveries).map((context) => + {context})} + + ); +} diff --git a/examples/worktree-proximity/src/events/session/start.tsx b/examples/worktree-proximity/src/events/session/start.tsx new file mode 100644 index 000000000..b93507ce4 --- /dev/null +++ b/examples/worktree-proximity/src/events/session/start.tsx @@ -0,0 +1,65 @@ +import { Agent } from '@agent-bundle/runtime'; +import type { AgentEventRouteProps } from 'agent-bundle'; +import React from 'react'; + +import { worktree } from '../../api.js'; +import { withNotices, withTopology } from '../../coordination.js'; +import { deliveryContexts, nativeString } from '../../event-support.js'; + +export const config = { + runtime: 'shared', + targets: ['claude', 'codex'], +}; + +export default async function SessionStart({ + canonical, + native, +}: AgentEventRouteProps) { + const currentWorktree = await worktree(); + if (currentWorktree.state === 'unavailable') { + return ( + + {`Worktree topology unavailable: ${currentWorktree.reason}`} + + ); + } + const sessionId = nativeString(native, 'session_id'); + if (sessionId === undefined) { + return ( + + Root actor unavailable: session/start omitted native session_id. + + ); + } + + const actorId = `session:${sessionId}`; + await withTopology(currentWorktree, async (topology) => { + await topology.dispatch('actorObserved', { + id: actorId, + kind: 'root', + provenance: { id: 'native' }, + status: 'active', + }, { + idempotencyKey: `${canonical.idempotencyKey}:actor`, + }); + await topology.dispatch('actorBound', { + actorId, + provenance: 'native', + worktreeRoot: currentWorktree.root, + }, { + idempotencyKey: `${canonical.idempotencyKey}:worktree`, + }); + }); + const deliveries = await withNotices( + currentWorktree, + actorId, + 'native', + async (notices) => notices.read(), + ); + const contexts = deliveryContexts(deliveries); + return ( + + {contexts.map((context) => {context})} + + ); +} diff --git a/examples/worktree-proximity/src/events/stop.tsx b/examples/worktree-proximity/src/events/stop.tsx new file mode 100644 index 000000000..5537b7d71 --- /dev/null +++ b/examples/worktree-proximity/src/events/stop.tsx @@ -0,0 +1,56 @@ +import { Agent } from '@agent-bundle/runtime'; +import type { AgentEventRouteProps } from 'agent-bundle'; +import React from 'react'; + +import { worktree } from '../api.js'; +import { withNotices, withTopology } from '../coordination.js'; +import { + actorForWorktree, + deliveryContexts, + nativeString, + type ResolvedActor, +} from '../event-support.js'; + +export const config = { + runtime: 'shared', + targets: ['claude', 'codex'], +}; + +export default async function Stop({ + canonical, + native, +}: AgentEventRouteProps) { + const currentWorktree = await worktree(); + if (currentWorktree.state === 'unavailable') { + return ( + + {`Actor stop unavailable: ${currentWorktree.reason}`} + + ); + } + const nativeActorId = nativeString(native, 'agent_id'); + const actor = await withTopology(currentWorktree, async (topology): Promise => { + const resolved = nativeActorId === undefined + ? (await actorForWorktree(topology, currentWorktree, canonical)).actor + : { id: nativeActorId, source: 'native' as const }; + await topology.dispatch('actorStopped', { + actorId: resolved.id, + observedAt: canonical.observedAt, + }, { + idempotencyKey: `${canonical.idempotencyKey}:stopped`, + }); + return resolved; + }); + const deliveries = await withNotices( + currentWorktree, + actor.id, + actor.source, + async (notices) => notices.read(), + ); + return ( + + {deliveryContexts(deliveries).map((context) => + {context})} + + ); +} diff --git a/examples/worktree-proximity/src/events/tool/after.tsx b/examples/worktree-proximity/src/events/tool/after.tsx new file mode 100644 index 000000000..b518dc58c --- /dev/null +++ b/examples/worktree-proximity/src/events/tool/after.tsx @@ -0,0 +1,55 @@ +import { Agent } from '@agent-bundle/runtime'; +import type { AgentEventRouteProps } from 'agent-bundle'; +import React from 'react'; + +import { worktree } from '../../api.js'; +import { withNotices, withTopology } from '../../coordination.js'; +import { actorForWorktree, deliveryContexts } from '../../event-support.js'; + +export const config = { + runtime: 'shared', + targets: ['claude', 'codex'], +}; + +export default async function AfterTool({ + canonical, +}: AgentEventRouteProps) { + const currentWorktree = await worktree(); + if (currentWorktree.state === 'unavailable') { + return ( + + {`Activity completion unavailable: ${currentWorktree.reason}`} + + ); + } + const actor = await withTopology(currentWorktree, async (topology) => { + const resolved = await actorForWorktree(topology, currentWorktree, canonical); + await topology.dispatch('intentRecorded', { + actorId: resolved.actor.id, + dependencies: [], + idempotencyKey: canonical.idempotencyKey, + observedAt: canonical.observedAt, + paths: [], + provenance: { + actorId: resolved.actor.source, + dependencies: 'native', + paths: 'native', + }, + }, { + idempotencyKey: `${canonical.idempotencyKey}:completion`, + }); + return resolved.actor; + }); + const deliveries = await withNotices( + currentWorktree, + actor.id, + actor.source, + async (notices) => notices.read(), + ); + return ( + + {deliveryContexts(deliveries).map((context) => + {context})} + + ); +} diff --git a/examples/worktree-proximity/src/events/tool/before.tsx b/examples/worktree-proximity/src/events/tool/before.tsx new file mode 100644 index 000000000..1e691c738 --- /dev/null +++ b/examples/worktree-proximity/src/events/tool/before.tsx @@ -0,0 +1,100 @@ +import { Agent } from '@agent-bundle/runtime'; +import type { AgentEventRouteProps } from 'agent-bundle'; +import React from 'react'; + +import { worktree } from '../../api.js'; +import { withNotices, withTopology } from '../../coordination.js'; +import { findProximity } from '../../domain/proximity.js'; +import { + actorForWorktree, + deliveryContexts, + extractIntent, +} from '../../event-support.js'; + +export const config = { + runtime: 'shared', + targets: ['claude', 'codex'], +}; + +export default async function BeforeTool({ + canonical, + native, +}: AgentEventRouteProps) { + const currentWorktree = await worktree(); + if (currentWorktree.state === 'unavailable') { + return ( + + {`Proximity detection unavailable: ${currentWorktree.reason}`} + + ); + } + const intent = extractIntent(native); + const resolution = await withTopology(currentWorktree, async (topology) => { + const { actor } = await actorForWorktree(topology, currentWorktree, canonical); + const committed = await topology.dispatch('intentRecorded', { + actorId: actor.id, + dependencies: [...intent.dependencies], + idempotencyKey: canonical.idempotencyKey, + observedAt: canonical.observedAt, + paths: [...intent.paths], + provenance: { + actorId: actor.source, + dependencies: 'native', + paths: 'native', + }, + }, { + idempotencyKey: `${canonical.idempotencyKey}:intent`, + }); + return { + actor, + conflicts: findProximity(committed.state, currentWorktree.root, { + actorId: actor.id, + dependencies: intent.dependencies, + paths: intent.paths, + }), + }; + }); + + const deliveryAndPublication = await withNotices( + currentWorktree, + resolution.actor.id, + resolution.actor.source, + async (notices) => { + const deliveries = await notices.read(); + for (const [index, conflict] of resolution.conflicts.entries()) { + await notices.publish({ + content: { + root: { + kind: 'text', + text: conflict.summary, + }, + status: 'success', + version: 1, + }, + dedupeKey: `proximity:${resolution.actor.id}:${conflict.actorId}:${conflict.summary}`, + priority: 'high', + recipient: { + actor: { id: conflict.actorId }, + }, + }, { + idempotencyKey: `${canonical.idempotencyKey}:notice:${String(index)}`, + }); + } + return deliveryContexts(deliveries); + }, + ); + const warnings = resolution.conflicts.map((conflict) => + `Proximity warning for ${resolution.actor.id}: ${conflict.summary}`); + const reason = warnings.join(' '); + const value = reason === '' + ? { outcome: 'continue' as const } + : { outcome: 'continue' as const, reason }; + + return ( + + {deliveryAndPublication.map((context) => + {context})} + {warnings.map((warning) => {warning})} + + ); +} diff --git a/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx b/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx new file mode 100644 index 000000000..b51223539 --- /dev/null +++ b/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx @@ -0,0 +1,90 @@ +import { Agent, type JsonValue } from '@agent-bundle/runtime'; +import type { ToolConfig, ToolRouteProps } from 'agent-bundle'; +import React from 'react'; +import { z } from 'zod'; + +import { worktree } from '../../../api.js'; +import { + readNoticeLedger, + stateRootFor, + withTopology, +} from '../../../coordination.js'; +import { ActorSchema } from '../../../state.js'; + +export const config = { + annotations: { readOnlyHint: true }, + description: 'Show the durable worktree topology, active intents, refusals, and pending directed notices.', +} satisfies ToolConfig; + +export const inputSchema = z + .object({ + actorId: z.string().min(1).optional(), + }) + .strict(); + +export const resultSchema = z + .object({ + activeActivities: z.number().int().nonnegative(), + actors: z.array(ActorSchema), + pendingNotices: z.number().int().nonnegative(), + reason: z.string().optional(), + refusals: z.number().int().nonnegative(), + state: z.enum(['available', 'unavailable']), + stateRoot: z.string().optional(), + }) + .strict(); + +type StatusResult = z.output; + +export default async function Status({ + input, +}: ToolRouteProps) { + const currentWorktree = await worktree(); + let result: StatusResult; + if (currentWorktree.state === 'unavailable') { + result = { + activeActivities: 0, + actors: [], + pendingNotices: 0, + reason: currentWorktree.reason, + refusals: 0, + state: 'unavailable', + }; + } else { + const topology = await withTopology(currentWorktree, async (store) => (await store.read()).state); + const notices = await readNoticeLedger(currentWorktree); + const actors = input.actorId === undefined + ? topology.actors + : topology.actors.filter((actor) => actor.id === input.actorId); + const visibleIds = new Set(actors.map((actor) => actor.id)); + result = { + activeActivities: topology.activities.filter( + (activity) => + visibleIds.has(activity.actorId) + && (activity.paths.length > 0 || activity.dependencies.length > 0), + ).length, + actors, + pendingNotices: notices.notices.filter((notice) => notice.state === 'pending').length, + refusals: topology.refusals.length, + state: 'available', + stateRoot: stateRootFor(currentWorktree), + }; + } + + const markdown = result.state === 'available' + ? [ + '# Worktree proximity status', + '', + `- Actors: ${String(result.actors.length)}`, + `- Active activities: ${String(result.activeActivities)}`, + `- Pending notices: ${String(result.pendingNotices)}`, + `- Refused edges: ${String(result.refusals)}`, + ].join('\n') + : `# Worktree proximity status\n\nUnavailable: ${result.reason ?? 'unknown reason'}`; + return ( + + {markdown} + + + ); +} diff --git a/examples/worktree-proximity/src/providers/agent-topology.ts b/examples/worktree-proximity/src/providers/agent-topology.ts new file mode 100644 index 000000000..45b877ec4 --- /dev/null +++ b/examples/worktree-proximity/src/providers/agent-topology.ts @@ -0,0 +1,59 @@ +import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'; + +import { stateRootFor } from '../coordination.js'; +import { + topologyStateDefinition, + type TopologyState, +} from '../state.js'; +import gitWorktreeProvider from './git-worktree.js'; + +interface ProviderContext { + readonly invocation: { + readonly kind: string; + readonly props: Readonly>; + }; + readonly signal: AbortSignal; +} + +export type AgentTopologyProviderValue = + | { + readonly snapshot: TopologyState; + readonly state: 'available'; + readonly stateRoot: string; + } + | { + readonly reason: string; + readonly state: 'unavailable'; + }; + +export default async function agentTopologyProvider( + context: ProviderContext, +): Promise { + const worktree = await gitWorktreeProvider(context); + if (worktree.state === 'unavailable') { + return { + reason: worktree.reason, + state: 'unavailable', + }; + } + + const stateRoot = stateRootFor(worktree); + const driver = createSqliteStateDriver({ root: stateRoot }); + try { + const store = await driver.open(topologyStateDefinition); + const snapshot = await store.read({ signal: context.signal }); + return { + snapshot: snapshot.state, + state: 'available', + stateRoot, + }; + } catch (error) { + return { + reason: + `Topology state is unavailable: ${error instanceof Error ? error.message : String(error)}`, + state: 'unavailable', + }; + } finally { + await driver.close(); + } +} diff --git a/examples/worktree-proximity/src/providers/git-worktree.ts b/examples/worktree-proximity/src/providers/git-worktree.ts new file mode 100644 index 000000000..ff1afd90b --- /dev/null +++ b/examples/worktree-proximity/src/providers/git-worktree.ts @@ -0,0 +1,78 @@ +import { execFile } from 'node:child_process'; +import { resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import type { WorktreeProviderValue } from '../api.js'; + +interface ProviderContext { + readonly invocation: { + readonly kind: string; + readonly props: Readonly>; + }; + readonly signal: AbortSignal; +} + +const execFileAsync = promisify(execFile); + +const eventCwd = (context: ProviderContext): string | undefined => { + if (context.invocation.kind !== 'event') return undefined; + const payload = context.invocation.props.payload; + if (payload === null || typeof payload !== 'object') return undefined; + const native = (payload as { readonly native?: unknown }).native; + if (native === null || typeof native !== 'object') return undefined; + const cwd = (native as { readonly cwd?: unknown }).cwd; + return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : undefined; +}; + +const absoluteGitPath = (cwd: string, value: string): string => + resolve(cwd, value); + +export default async function gitWorktreeProvider( + context: ProviderContext, +): Promise { + const nativeCwd = eventCwd(context); + const cwd = nativeCwd ?? process.cwd(); + const source = nativeCwd === undefined ? 'process-cwd' : 'native-cwd'; + if (cwd.trim() === '') { + return { + reason: 'No working directory was available for git worktree discovery.', + state: 'unavailable', + }; + } + + const git = async (...args: readonly string[]): Promise => { + const result = await execFileAsync( + 'git', + ['-C', cwd, ...args], + { encoding: 'utf8', signal: context.signal }, + ); + return result.stdout.trim(); + }; + + try { + const [root, branch, head, commonDirValue, gitDirValue] = await Promise.all([ + git('rev-parse', '--show-toplevel'), + git('rev-parse', '--abbrev-ref', 'HEAD'), + git('rev-parse', 'HEAD'), + git('rev-parse', '--git-common-dir'), + git('rev-parse', '--git-dir'), + ]); + const commonDir = absoluteGitPath(cwd, commonDirValue); + const gitDir = absoluteGitPath(cwd, gitDirValue); + return { + branch, + commonDir, + head, + isLinkedWorktree: gitDir !== commonDir, + root: resolve(root), + source, + state: 'available', + }; + } catch (error) { + return { + reason: + `Git worktree identity is unavailable for ${cwd}: ${error instanceof Error ? error.message : String(error)}`, + state: 'unavailable', + }; + } +} diff --git a/examples/worktree-proximity/src/state.ts b/examples/worktree-proximity/src/state.ts new file mode 100644 index 000000000..f7b53818b --- /dev/null +++ b/examples/worktree-proximity/src/state.ts @@ -0,0 +1,166 @@ +import { defineState } from '@agent-bundle/runtime/state'; +import { z } from 'zod'; + +const nonEmpty = z.string().trim().min(1); +const provenance = z.enum(['native', 'derived']); + +export const ActorSchema = z + .object({ + id: nonEmpty, + kind: z.enum(['root', 'child']), + parentSessionId: nonEmpty.optional(), + provenance: z + .object({ + id: provenance, + parentSessionId: provenance.optional(), + worktreeRoot: provenance.optional(), + }) + .strict(), + status: z.enum(['active', 'stopped']), + worktreeRoot: nonEmpty.optional(), + }) + .strict(); + +export const ActivitySchema = z + .object({ + actorId: nonEmpty, + dependencies: z.array(nonEmpty), + idempotencyKey: nonEmpty, + observedAt: nonEmpty, + paths: z.array(nonEmpty), + provenance: z + .object({ + actorId: provenance, + dependencies: provenance, + paths: provenance, + }) + .strict(), + }) + .strict(); + +export const EdgeRefusalSchema = z + .object({ + idempotencyKey: nonEmpty, + observedAt: nonEmpty, + reason: nonEmpty, + sessionId: nonEmpty.optional(), + }) + .strict(); + +export const TopologyStateSchema = z + .object({ + activities: z.array(ActivitySchema), + actors: z.array(ActorSchema), + refusals: z.array(EdgeRefusalSchema), + }) + .strict(); + +export type Actor = z.output; +export type Activity = z.output; +export type EdgeRefusal = z.output; +export type TopologyState = z.output; + +const actorObservedSchema = ActorSchema.omit({ worktreeRoot: true }).strict(); +const actorBoundSchema = z + .object({ + actorId: nonEmpty, + provenance, + worktreeRoot: nonEmpty, + }) + .strict(); +const actorStoppedSchema = z + .object({ + actorId: nonEmpty, + observedAt: nonEmpty, + }) + .strict(); + +export const topologyEventSchemas = { + actorBound: actorBoundSchema, + actorObserved: actorObservedSchema, + actorStopped: actorStoppedSchema, + 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 const topologyStateDefinition = defineState({ + events: topologyEventSchemas, + id: 'worktree-proximity/topology', + initial: { + activities: [], + actors: [], + refusals: [], + }, + lifetime: 'workspace-durable', + reduce: (state, event): TopologyState => { + 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, + })), + }; + case 'intentRecorded': + return { + ...state, + activities: [ + ...state.activities.filter((activity) => activity.actorId !== event.payload.actorId), + event.payload, + ], + }; + case 'actorStopped': + return { + ...state, + activities: state.activities.filter((activity) => activity.actorId !== event.payload.actorId), + actors: replaceActor(state.actors, event.payload.actorId, (actor) => ({ + ...actor, + status: 'stopped', + })), + }; + case 'edgeRefused': + return { + ...state, + refusals: [...state.refusals, event.payload], + }; + default: { + const unreachable: never = event; + throw new Error(`Unhandled topology event ${String(unreachable)}`); + } + } + }, + schema: TopologyStateSchema, + version: 1, +}); diff --git a/examples/worktree-proximity/tests/proximity.test.ts b/examples/worktree-proximity/tests/proximity.test.ts new file mode 100644 index 000000000..6063dd3c2 --- /dev/null +++ b/examples/worktree-proximity/tests/proximity.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from '@rstest/core'; + +import { findProximity } from '../src/domain/proximity.js'; +import type { TopologyState } from '../src/state.js'; + +const snapshot = (path: string, dependency: string): TopologyState => ({ + activities: [{ + actorId: 'agent-b', + dependencies: [dependency], + idempotencyKey: 'intent-b', + observedAt: '2026-09-01T20:00:00.000Z', + paths: [path], + provenance: { + actorId: 'native', + dependencies: 'native', + paths: 'native', + }, + }], + actors: [ + { + id: 'agent-a', + kind: 'child', + parentSessionId: 'root-session', + provenance: { + id: 'native', + parentSessionId: 'native', + worktreeRoot: 'native', + }, + status: 'active', + worktreeRoot: '/repo/worktrees/a', + }, + { + id: 'agent-b', + kind: 'child', + parentSessionId: 'root-session', + provenance: { + id: 'native', + parentSessionId: 'native', + worktreeRoot: 'native', + }, + status: 'active', + worktreeRoot: '/repo/worktrees/b', + }, + ], + refusals: [], +}); + +describe('findProximity', () => { + it('returns no conflict for distinct paths and dependencies', () => { + expect(findProximity( + snapshot('src/catalog.ts', 'zod'), + '/repo/worktrees/a', + { actorId: 'agent-a', dependencies: ['react'], paths: ['src/player.ts'] }, + )).toEqual([]); + }); + + it('reports a normalized repo-relative path overlap in another worktree', () => { + expect(findProximity( + snapshot('./src/shared.ts', 'zod'), + '/repo/worktrees/a', + { actorId: 'agent-a', dependencies: [], paths: ['/repo/worktrees/a/src/shared.ts'] }, + )).toEqual([{ + actorId: 'agent-b', + summary: + 'Worktrees /repo/worktrees/a and /repo/worktrees/b both intend to change path src/shared.ts.', + }]); + }); + + it('reports a case-insensitive dependency overlap in another worktree', () => { + expect(findProximity( + snapshot('src/catalog.ts', 'Zod'), + '/repo/worktrees/a', + { actorId: 'agent-a', dependencies: ['zod'], paths: [] }, + )).toEqual([{ + actorId: 'agent-b', + summary: + 'Worktrees /repo/worktrees/a and /repo/worktrees/b both depend on zod.', + }]); + }); + + it('ignores an actor in the same worktree', () => { + const sameWorktree: TopologyState = { + ...snapshot('src/shared.ts', 'zod'), + actors: snapshot('src/shared.ts', 'zod').actors.map((actor) => ({ + ...actor, + worktreeRoot: '/repo/worktrees/a', + })), + }; + expect(findProximity( + sameWorktree, + '/repo/worktrees/a', + { actorId: 'agent-a', dependencies: ['zod'], paths: ['src/shared.ts'] }, + )).toEqual([]); + }); +}); diff --git a/examples/worktree-proximity/tests/route-unit/routes.test.ts b/examples/worktree-proximity/tests/route-unit/routes.test.ts new file mode 100644 index 000000000..90a7f7297 --- /dev/null +++ b/examples/worktree-proximity/tests/route-unit/routes.test.ts @@ -0,0 +1,311 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from '@rstest/core'; +import { available } from '@agent-bundle/runtime'; +import { agentNoticeStateDefinition } from '@agent-bundle/runtime/notices'; +import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'; +import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test'; + +import { topologyStateDefinition } from '../../src/state.js'; + +const manifest = testManifest(); + +const worktrees = { + root: '/repo', + a: '/repo/.worktrees/a', + b: '/repo/.worktrees/b', +} as const; + +let stateRoot: string; +let previousStateRoot: string | undefined; +let sequence = 0; + +const provider = (root: string) => ({ + branch: `branch-${root.split('/').at(-1) ?? 'root'}`, + commonDir: '/repo/.git', + head: '490cb102ebb247d4d0b1a4ce5178ddfb66c9e5dd', + isLinkedWorktree: root !== worktrees.root, + root, + source: 'native-cwd' as const, + state: 'available' as const, +}); + +const eventInput = ( + event: 'agent/start' | 'session/start' | 'stop' | 'tool/after' | 'tool/before', + native: Record, + id: string, +) => ({ + canonical: { + event, + idempotencyKey: id, + observedAt: `2026-09-01T20:00:${String(sequence++).padStart(2, '0')}.000Z`, + provenance: { + host: 'claude', + hostContractRevision: 'route-unit', + nativeEvent: native.hook_event_name as string, + source: 'native', + }, + sequence, + }, + native, +}); + +const renderEvent = async ( + route: string, + event: Parameters[0], + native: Record, + id: string, + worktreeRoot: string, + actorId?: string, +) => renderRoute(route, { + context: { + actor: actorId === undefined ? undefined : available({ id: actorId }, 'native'), + host: available({ name: 'claude' }, 'native'), + invocation: { + id: `invocation:${id}`, + startedAt: `2026-09-01T20:01:${String(sequence).padStart(2, '0')}.000Z`, + }, + providers: { gitWorktree: provider(worktreeRoot) }, + session: available({ sessionId: 'root-session' }, 'native'), + workspace: available({ root: '/repo' }, 'native'), + }, + input: eventInput(event, native, id), +}); + +const bindActors = async (): Promise => { + await renderEvent( + 'event:session/start', + 'session/start', + { cwd: worktrees.root, hook_event_name: 'SessionStart', session_id: 'root-session' }, + 'root:start', + worktrees.root, + 'session:root-session', + ); + await renderEvent( + 'event:agent/start', + 'agent/start', + { + agent_id: 'agent-a', + agent_type: 'implementation', + cwd: worktrees.a, + hook_event_name: 'SubagentStart', + session_id: 'root-session', + }, + 'agent-a:start', + worktrees.a, + 'agent-a', + ); + await renderEvent( + 'event:agent/start', + 'agent/start', + { + agent_id: 'agent-b', + agent_type: 'implementation', + cwd: worktrees.b, + hook_event_name: 'SubagentStart', + session_id: 'root-session', + }, + 'agent-b:start', + worktrees.b, + 'agent-b', + ); +}; + +const recordIntent = ( + actorId: 'agent-a' | 'agent-b', + root: string, + path: string, + id: string, + deps = '', +) => renderEvent( + 'event:tool/before', + 'tool/before', + { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'root-session', + tool_input: { deps, file_path: path }, + tool_name: 'Edit', + }, + id, + root, + actorId, +); + +beforeEach(async () => { + stateRoot = await mkdtemp(join(tmpdir(), 'worktree-proximity-route-unit-')); + previousStateRoot = process.env.WORKTREE_PROXIMITY_STATE_DIR; + process.env.WORKTREE_PROXIMITY_STATE_DIR = stateRoot; + sequence = 0; +}); + +afterEach(async () => { + if (previousStateRoot === undefined) delete process.env.WORKTREE_PROXIMITY_STATE_DIR; + else process.env.WORKTREE_PROXIMITY_STATE_DIR = previousStateRoot; + await rm(stateRoot, { force: true, recursive: true }); +}); + +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:session/start', + 'event:stop', + 'event:tool/after', + 'event:tool/before', + 'tool:coordinator/status', + ])); +}); + +describe('worktree proximity journeys', () => { + it('does not warn when active paths and dependencies do not overlap (journey 3)', async () => { + await bindActors(); + await recordIntent('agent-b', worktrees.b, 'src/catalog.ts', 'intent:b', 'deps:zod'); + const rendered = await recordIntent('agent-a', worktrees.a, 'src/player.ts', 'intent:a', 'deps:react'); + + expectDocument(rendered).toHaveStatus('success').toHaveNodeKinds(['result']); + expect(rendered.document.value).toEqual({ outcome: 'continue' }); + }); + + it('warns without denying and publishes a pending directed notice (journeys 4 and 5)', async () => { + await bindActors(); + await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); + const rendered = await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); + + expectDocument(rendered) + .toHaveStatus('success') + .toContainContext('Proximity warning') + .toContainContext('src/shared.ts'); + expect(rendered.document.value).toMatchObject({ outcome: 'continue' }); + + const driver = createSqliteStateDriver({ root: stateRoot }); + try { + const store = await driver.open(agentNoticeStateDefinition()); + const snapshot = await store.read(); + expect(snapshot.state.notices).toEqual([ + expect.objectContaining({ + recipient: { actor: { id: 'agent-b' } }, + state: 'pending', + }), + ]); + } finally { + await driver.close(); + } + }); + + it('attempts and surfaces a notice on the recipient next event (journey 6)', async () => { + await bindActors(); + await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); + await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); + + const delivered = await renderEvent( + 'event:tool/after', + 'tool/after', + { + cwd: worktrees.b, + hook_event_name: 'PostToolUse', + session_id: 'root-session', + tool_input: { file_path: 'src/shared.ts' }, + tool_name: 'Edit', + }, + 'intent:b:after', + worktrees.b, + 'agent-b', + ); + + expectDocument(delivered) + .toHaveStatus('success') + .toContainContext('Directed proximity notice') + .toContainContext('src/shared.ts'); + + const driver = createSqliteStateDriver({ root: stateRoot }); + try { + const store = await driver.open(agentNoticeStateDefinition()); + const snapshot = await store.read(); + expect(snapshot.state.notices[0]).toMatchObject({ + attempts: [expect.objectContaining({ invocationId: 'invocation:intent:b:after' })], + state: 'attempted', + }); + } finally { + await driver.close(); + } + }); + + it('deduplicates a repeated native intent envelope (journey 7)', async () => { + await bindActors(); + await recordIntent('agent-a', worktrees.a, 'src/player.ts', 'intent:replayed'); + await recordIntent('agent-a', worktrees.a, 'src/player.ts', 'intent:replayed'); + + const driver = createSqliteStateDriver({ root: stateRoot }); + try { + const store = await driver.open(topologyStateDefinition); + const snapshot = await store.read(); + expect(snapshot.state.activities.filter((activity) => activity.actorId === 'agent-a')).toHaveLength(1); + expect(snapshot.revision).toBe(7); + } finally { + await driver.close(); + } + }); + + it('records a refusal and never fabricates an edge without native agent identity (journey 8)', async () => { + const rendered = await renderEvent( + 'event:agent/start', + 'agent/start', + { + agent_type: 'fixture-without-identity', + cwd: worktrees.a, + hook_event_name: 'SubagentStart', + session_id: 'root-session', + }, + 'agent:missing-id', + worktrees.a, + ); + + expectDocument(rendered) + .toHaveStatus('success') + .toContainContext('Parent identity unavailable') + .toContainContext('refused to fabricate'); + + const driver = createSqliteStateDriver({ root: stateRoot }); + try { + const store = await driver.open(topologyStateDefinition); + const snapshot = await store.read(); + expect(snapshot.state.actors).toEqual([]); + expect(snapshot.state.refusals).toEqual([ + expect.objectContaining({ + reason: 'agent/start omitted native agent_id; refused to fabricate a topology edge', + }), + ]); + } finally { + await driver.close(); + } + }); + + it('renders the coordinator status with topology and pending notice counts', async () => { + await bindActors(); + await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); + await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); + + const rendered = await renderRoute('tool:coordinator/status', { + context: { + providers: { gitWorktree: provider(worktrees.root) }, + }, + input: {}, + }); + + expectDocument(rendered) + .toHaveStatus('success') + .toContainMarkdown('Worktree proximity status'); + 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 }), + ]), + pendingNotices: 1, + refusals: 0, + }); + }); +}); diff --git a/examples/worktree-proximity/tsconfig.json b/examples/worktree-proximity/tsconfig.json new file mode 100644 index 000000000..67cbb7fcf --- /dev/null +++ b/examples/worktree-proximity/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx" + }, + "include": [ + "agent-bundle.config.ts", + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.ts", + "tests/**/*.tsx" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d805d3d28..1fb8d377f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,6 +197,28 @@ importers: specifier: workspace:* version: link:../../packages/agent-bundle + examples/worktree-proximity: + dependencies: + '@agent-bundle/runtime': + specifier: workspace:* + version: link:../../packages/rsc-runtime + react: + specifier: 19.2.8 + version: 19.2.8 + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@rstest/core': + specifier: 0.11.10 + version: 0.11.10 + '@types/react': + specifier: 19.2.18 + version: 19.2.18 + agent-bundle: + specifier: workspace:* + version: link:../../packages/agent-bundle + packages/agent-bundle: dependencies: '@agent-bundle/runtime': @@ -761,16 +783,6 @@ packages: core-js: optional: true - '@rsbuild/core@2.2.0': - resolution: {integrity: sha512-UnBBfxWIDKVdLz2BUBq7hFBatwLclJ4moFhlDFg+pFBPPJ1g34MmCbGUC0c9Mo1DhPGdYJG69qMIldh5MvC74w==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - core-js: '>= 3.0.0' - peerDependenciesMeta: - core-js: - optional: true - '@rsbuild/core@2.2.1': resolution: {integrity: sha512-JcGtG4bo7PBihj6fBL6gaxaJihqLf7nGWW/t4zEmXpMJkV6XNdr1jAo9B0xI4mLAOmtFeva8cUbn9SE2ZEmAIw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -860,11 +872,6 @@ packages: cpu: [arm64] os: [darwin] - '@rspack/binding-darwin-arm64@2.2.0': - resolution: {integrity: sha512-KAVVT7hp3NBjtc/RY2UtOjzzc8i+s4pIhW1p52UV+Aev6ywQCu3dXwkHTonpPvJO3hqLXc4zIMH5l4HbMqBm4g==} - cpu: [arm64] - os: [darwin] - '@rspack/binding-darwin-arm64@2.2.1': resolution: {integrity: sha512-Y/Naw/7V76QiUYdYRuBzBZtzRjt/3fjDUuF8GK0+/BO7BP1RrpY4tk1ln+iiqegRUD8u9uGn08fi8No1rwfyUg==} cpu: [arm64] @@ -875,11 +882,6 @@ packages: cpu: [x64] os: [darwin] - '@rspack/binding-darwin-x64@2.2.0': - resolution: {integrity: sha512-rzyJCX99aFwl540trsVMNZOgK4+IFm2d5+YeP+RdNo9Uprxloz8vHz0J4dYtaq6MRiCAyM60dAwEa3wJMwqWAQ==} - cpu: [x64] - os: [darwin] - '@rspack/binding-darwin-x64@2.2.1': resolution: {integrity: sha512-rTIG/xZIW7RbEEuMR9hnNn5dv3fDBpX0N4FAQUwfhUYy3tN2+3vibTTq/Nj1Sd9Vn4yWydbWIEwBX6m/aGejig==} cpu: [x64] @@ -891,12 +893,6 @@ packages: os: [linux] libc: [glibc] - '@rspack/binding-linux-arm64-gnu@2.2.0': - resolution: {integrity: sha512-0t8QOiOMcBV7RvPSsTJ5DQ4QCK6FIyUZy77qbxnS6asGTOXPZZn7V5cL26IxEv/wuHdQ6tQOXheau1fi+gGyBQ==} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@rspack/binding-linux-arm64-gnu@2.2.1': resolution: {integrity: sha512-53rAMU6Hqiat21IMU1hTt4Si2F33h+ZbXOt+Y18W39AFjcwmleIBId2u7beqJeGXLJuBgIAm31jcbJj/PN7mWQ==} cpu: [arm64] @@ -909,12 +905,6 @@ packages: os: [linux] libc: [musl] - '@rspack/binding-linux-arm64-musl@2.2.0': - resolution: {integrity: sha512-BAvCukqcuHxUdE294ITCohvhVkEklW8RbkKkR36Uo0WyIiMPGrnvPjARPn0/4Q4xMAz7lUmC60sZrvJHlAOKMw==} - cpu: [arm64] - os: [linux] - libc: [musl] - '@rspack/binding-linux-arm64-musl@2.2.1': resolution: {integrity: sha512-o7zFiWkt4MqSfSdTbxUdF27fcrWKpRizcuVB8H3yd2G6xW9V2OfYbhVGQnOlbKi+GK74RkCmoJtANB+QboIqKQ==} cpu: [arm64] @@ -927,12 +917,6 @@ packages: os: [linux] libc: [glibc] - '@rspack/binding-linux-ppc64-gnu@2.2.0': - resolution: {integrity: sha512-nCHqZLv/E8nm2ccGkb00F5DQtXxzGy3W3X73ArA+N0+zXJUnzRcSRSwr7AE8pVgP/FYfX4yMFgUXy0g0YxYGRA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - '@rspack/binding-linux-ppc64-gnu@2.2.1': resolution: {integrity: sha512-pvx1oeg1z7cr8OcNt7PAt1SJTHzdsN/pvu7HkGnfks2fWE7GDs9DLL2KvN7tUkzQvJm6bGgUwqSCOrqV4uSwAg==} cpu: [ppc64] @@ -945,12 +929,6 @@ packages: os: [linux] libc: [glibc] - '@rspack/binding-linux-riscv64-gnu@2.2.0': - resolution: {integrity: sha512-CA3WEqKFDI6FAZTnCho2n9pmdPWZYAW/S8mqgxd0cx2Jix43at3VyLxhCC7ED5A9WBSFn/AdHaIbVtgoQHVhWA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - '@rspack/binding-linux-riscv64-gnu@2.2.1': resolution: {integrity: sha512-WQ6P94Wz2tgwOsgifTWP2/bV63iZH5+rkxngQwF22FC8KmIXXy/Yug7lyMH1ld8Hzg4p1qXjBBr4i1cCyJTR+w==} cpu: [riscv64] @@ -963,12 +941,6 @@ packages: os: [linux] libc: [musl] - '@rspack/binding-linux-riscv64-musl@2.2.0': - resolution: {integrity: sha512-kHB960oClkoPRPZ6sdkhRvqbdRIlbpIMYd/Tbxfmn3DWQahiCk1pkUFJbOtFq3EgESxZISV4THl442W2Y57HvQ==} - cpu: [riscv64] - os: [linux] - libc: [musl] - '@rspack/binding-linux-riscv64-musl@2.2.1': resolution: {integrity: sha512-GEFUFHQkjKU7OYyHXnrIo8wWcUHM7jeKov5z6Lxd3i+3hKo11yBOgb7b+uD94Rx2tPuWF4jyFdWmcNu46Njb/A==} cpu: [riscv64] @@ -981,12 +953,6 @@ packages: os: [linux] libc: [glibc] - '@rspack/binding-linux-s390x-gnu@2.2.0': - resolution: {integrity: sha512-lVBdiffVo1jq0P0jT36jNou2suLB4ueQI4aWUs+HM+h67YPBtVKWu/mo5Wh59+8nowgcZmYaFM5hdH69963I9w==} - cpu: [s390x] - os: [linux] - libc: [glibc] - '@rspack/binding-linux-s390x-gnu@2.2.1': resolution: {integrity: sha512-Cqw2UjSmFGZaw1EjQXClKDud86ThynGEEIazy2PZfPzZG4WjICK1WjdeFCJd7xWsSbUQ3jGyzb7/EHiDVa+sKA==} cpu: [s390x] @@ -999,12 +965,6 @@ packages: os: [linux] libc: [glibc] - '@rspack/binding-linux-x64-gnu@2.2.0': - resolution: {integrity: sha512-M49UaWspE0YJ3268DsquD8idEQTfjBDMvO/I8qccV/Z5T+Q98FJ+kIs5liUaTWb48OIbDEK+8ZKx5QzLbfVN6g==} - cpu: [x64] - os: [linux] - libc: [glibc] - '@rspack/binding-linux-x64-gnu@2.2.1': resolution: {integrity: sha512-QX+gRxg2CS9ri2fUG5eNurdsrOCWoJ56SsD2O7kcVGiRm+S2+muNb/QhkdkAdt/u/m++7Ve5TMIpHTnaKYCpCw==} cpu: [x64] @@ -1017,12 +977,6 @@ packages: os: [linux] libc: [musl] - '@rspack/binding-linux-x64-musl@2.2.0': - resolution: {integrity: sha512-YYbs0wmey+5blhEQDE4Dax3TwJtqfGwe2QBm3OLphlBHo/fcZVvimzKkMV0/pVrZTLy2z5ZAwNhGMY64bNr77w==} - cpu: [x64] - os: [linux] - libc: [musl] - '@rspack/binding-linux-x64-musl@2.2.1': resolution: {integrity: sha512-mBZQl1NdGbEB3y5M9d0tkuF7RL1GLz3Hb3gqFa3QRZBymP9PCV83Jiji8s2PJimNUJIVcdZRIw8+VGYs/35c2A==} cpu: [x64] @@ -1033,10 +987,6 @@ packages: resolution: {integrity: sha512-KY5YbWbuvYcoaLXnV+vzZOvGRCeb6jt4EpVpKdph1h1IJjwX/ju15EQ+GOe3iecZEdf0OttQcNVcwBkLkFT9ag==} cpu: [wasm32] - '@rspack/binding-wasm32-wasi@2.2.0': - resolution: {integrity: sha512-rerLPTN/HD4EvLNWs3O2N+Eb37eGvLRIP3dXXc3n+UzTebOepAsahNn44vXeRBsE4m/pHkpDJjwgWTytgQ2gBw==} - cpu: [wasm32] - '@rspack/binding-wasm32-wasi@2.2.1': resolution: {integrity: sha512-/d2ImKDS+lT+FJ07MxKBeUkTat84tr2Nm2+nIRt8HmZK7/N8odkQml9vb4MHr8E7oYOp4B+jm6W5frdRzKrkJQ==} cpu: [wasm32] @@ -1046,11 +996,6 @@ packages: cpu: [arm64] os: [win32] - '@rspack/binding-win32-arm64-msvc@2.2.0': - resolution: {integrity: sha512-JUAmnbOQYGTRyX28vls/MOMonZWcmcCi5YtEq6YMc8Xqh3Qx0HUwaLM/I1xr/N9BX3b8CV0dQDOpNuBc2ei+CA==} - cpu: [arm64] - os: [win32] - '@rspack/binding-win32-arm64-msvc@2.2.1': resolution: {integrity: sha512-TfmaKPF3KC7uoZb6A+8ZUbLS8g8P5EdeXFGLCaJ+UgdkJ20TsapcfJXZXWNnKzFEkV8dUO/t5oNxNLYy2URusw==} cpu: [arm64] @@ -1061,11 +1006,6 @@ packages: cpu: [ia32] os: [win32] - '@rspack/binding-win32-ia32-msvc@2.2.0': - resolution: {integrity: sha512-wOmQRUaOG0eWH/fnfslA9yK9xKfaq9X+3Xa1TdTJnTqlo0ARJYs6A+Lzjbs7cxdY/o1f12Xe00BG3nQozReUOg==} - cpu: [ia32] - os: [win32] - '@rspack/binding-win32-ia32-msvc@2.2.1': resolution: {integrity: sha512-rdBXayngvpQFMSUIC4b71FDZrSBJszSHNEkln/Nis3a17DMAE7IkgJcqe7SqLWbk2OPF/N920AOWmBEDQQCaMg==} cpu: [ia32] @@ -1076,11 +1016,6 @@ packages: cpu: [x64] os: [win32] - '@rspack/binding-win32-x64-msvc@2.2.0': - resolution: {integrity: sha512-v6/3bFr9+i7hRpgulL9b5qCvZL0VgR4vQGQNqOWezUzZmPUj9LYpvB0L9xZIVwDQ2ug/xBiA58bfg5IbESgoyw==} - cpu: [x64] - os: [win32] - '@rspack/binding-win32-x64-msvc@2.2.1': resolution: {integrity: sha512-l3K4s7nrQJc+3LacPFjZGjX8Jk1sf8Q4TK6IXAsdSucOjTqYSpD8PMl2NUXCBDXOl8T2V4v323z1LfxwW8BPmA==} cpu: [x64] @@ -1089,9 +1024,6 @@ packages: '@rspack/binding@2.1.10': resolution: {integrity: sha512-vnu/UP5HnrND15lO9+VeG6eUrbTyycHNQNQ3XEiRiFojuoiGZkIZC3Hbzr8qQH44C6vScPODEPvvIVvLcO2LpQ==} - '@rspack/binding@2.2.0': - resolution: {integrity: sha512-nxZzJqqB0EmEKp6qjzFNkBb/SgGt0k0DSENrLvAJgvVvrm3waVsubD0cfxtPlZY/rd5SzadzxWGEHRyFcds5nA==} - '@rspack/binding@2.2.1': resolution: {integrity: sha512-56TqztuEMd+aHGv1jDXnkJQGSLTb4NoO146flFxJqPG8931UdXPO2pNR9M0Q2Pz+GvmO0fLHGPLYBHoRVrRlHw==} @@ -1107,18 +1039,6 @@ packages: '@swc/helpers': optional: true - '@rspack/core@2.2.0': - resolution: {integrity: sha512-3W7oX0BAHbK4VlknH3lfyfRvupzxdZtyEa+DfKmdjzmIAcqYtHnFd0nLqp5dzitDPyDI1TIKkDhpB0AZJn0pVg==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0 - '@swc/helpers': ^0.5.23 - peerDependenciesMeta: - '@module-federation/runtime-tools': - optional: true - '@swc/helpers': - optional: true - '@rspack/core@2.2.1': resolution: {integrity: sha512-EHFX2oWCY1HkHJkG/Ev8HXCcl4gQzgETyairdIlBkKaypuW8i7kowBxUFzpHHg/ObF70vB3focToel9Wwg4MRA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3100,13 +3020,6 @@ snapshots: transitivePeerDependencies: - '@module-federation/runtime-tools' - '@rsbuild/core@2.2.0': - dependencies: - '@rspack/core': 2.2.0(@swc/helpers@0.5.23) - '@swc/helpers': 0.5.23 - transitivePeerDependencies: - - '@module-federation/runtime-tools' - '@rsbuild/core@2.2.1': dependencies: '@rspack/core': 2.2.1(@swc/helpers@0.5.23) @@ -3175,90 +3088,60 @@ snapshots: '@rspack/binding-darwin-arm64@2.1.10': optional: true - '@rspack/binding-darwin-arm64@2.2.0': - optional: true - '@rspack/binding-darwin-arm64@2.2.1': optional: true '@rspack/binding-darwin-x64@2.1.10': optional: true - '@rspack/binding-darwin-x64@2.2.0': - optional: true - '@rspack/binding-darwin-x64@2.2.1': optional: true '@rspack/binding-linux-arm64-gnu@2.1.10': optional: true - '@rspack/binding-linux-arm64-gnu@2.2.0': - optional: true - '@rspack/binding-linux-arm64-gnu@2.2.1': optional: true '@rspack/binding-linux-arm64-musl@2.1.10': optional: true - '@rspack/binding-linux-arm64-musl@2.2.0': - optional: true - '@rspack/binding-linux-arm64-musl@2.2.1': optional: true '@rspack/binding-linux-ppc64-gnu@2.1.10': optional: true - '@rspack/binding-linux-ppc64-gnu@2.2.0': - optional: true - '@rspack/binding-linux-ppc64-gnu@2.2.1': optional: true '@rspack/binding-linux-riscv64-gnu@2.1.10': optional: true - '@rspack/binding-linux-riscv64-gnu@2.2.0': - optional: true - '@rspack/binding-linux-riscv64-gnu@2.2.1': optional: true '@rspack/binding-linux-riscv64-musl@2.1.10': optional: true - '@rspack/binding-linux-riscv64-musl@2.2.0': - optional: true - '@rspack/binding-linux-riscv64-musl@2.2.1': optional: true '@rspack/binding-linux-s390x-gnu@2.1.10': optional: true - '@rspack/binding-linux-s390x-gnu@2.2.0': - optional: true - '@rspack/binding-linux-s390x-gnu@2.2.1': optional: true '@rspack/binding-linux-x64-gnu@2.1.10': optional: true - '@rspack/binding-linux-x64-gnu@2.2.0': - optional: true - '@rspack/binding-linux-x64-gnu@2.2.1': optional: true '@rspack/binding-linux-x64-musl@2.1.10': optional: true - '@rspack/binding-linux-x64-musl@2.2.0': - optional: true - '@rspack/binding-linux-x64-musl@2.2.1': optional: true @@ -3269,13 +3152,6 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) optional: true - '@rspack/binding-wasm32-wasi@2.2.0': - dependencies: - '@emnapi/core': 1.11.3 - '@emnapi/runtime': 1.11.3 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) - optional: true - '@rspack/binding-wasm32-wasi@2.2.1': dependencies: '@emnapi/core': 1.11.3 @@ -3286,27 +3162,18 @@ snapshots: '@rspack/binding-win32-arm64-msvc@2.1.10': optional: true - '@rspack/binding-win32-arm64-msvc@2.2.0': - optional: true - '@rspack/binding-win32-arm64-msvc@2.2.1': optional: true '@rspack/binding-win32-ia32-msvc@2.1.10': optional: true - '@rspack/binding-win32-ia32-msvc@2.2.0': - optional: true - '@rspack/binding-win32-ia32-msvc@2.2.1': optional: true '@rspack/binding-win32-x64-msvc@2.1.10': optional: true - '@rspack/binding-win32-x64-msvc@2.2.0': - optional: true - '@rspack/binding-win32-x64-msvc@2.2.1': optional: true @@ -3327,23 +3194,6 @@ snapshots: '@rspack/binding-win32-ia32-msvc': 2.1.10 '@rspack/binding-win32-x64-msvc': 2.1.10 - '@rspack/binding@2.2.0': - optionalDependencies: - '@rspack/binding-darwin-arm64': 2.2.0 - '@rspack/binding-darwin-x64': 2.2.0 - '@rspack/binding-linux-arm64-gnu': 2.2.0 - '@rspack/binding-linux-arm64-musl': 2.2.0 - '@rspack/binding-linux-ppc64-gnu': 2.2.0 - '@rspack/binding-linux-riscv64-gnu': 2.2.0 - '@rspack/binding-linux-riscv64-musl': 2.2.0 - '@rspack/binding-linux-s390x-gnu': 2.2.0 - '@rspack/binding-linux-x64-gnu': 2.2.0 - '@rspack/binding-linux-x64-musl': 2.2.0 - '@rspack/binding-wasm32-wasi': 2.2.0 - '@rspack/binding-win32-arm64-msvc': 2.2.0 - '@rspack/binding-win32-ia32-msvc': 2.2.0 - '@rspack/binding-win32-x64-msvc': 2.2.0 - '@rspack/binding@2.2.1': optionalDependencies: '@rspack/binding-darwin-arm64': 2.2.1 @@ -3367,12 +3217,6 @@ snapshots: optionalDependencies: '@swc/helpers': 0.5.23 - '@rspack/core@2.2.0(@swc/helpers@0.5.23)': - dependencies: - '@rspack/binding': 2.2.0 - optionalDependencies: - '@swc/helpers': 0.5.23 - '@rspack/core@2.2.1(@swc/helpers@0.5.23)': dependencies: '@rspack/binding': 2.2.1 @@ -3419,7 +3263,7 @@ snapshots: '@rstest/core@0.11.10': dependencies: - '@rsbuild/core': 2.2.0 + '@rsbuild/core': 2.2.1 '@types/chai': 5.2.3 transitivePeerDependencies: - '@module-federation/runtime-tools' From 70eee08a7c7000b3945d9e5ca9d55fe010e07c2a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 01:57:24 +0000 Subject: [PATCH 2/2] feat(examples): finish mounted worktree proximity journeys Use generated request state and notice handles so cross-render coordination is durable, testable, and honest when capabilities are unavailable. --- examples/worktree-proximity/README.md | 70 +++--- .../worktree-proximity/src/coordination.ts | 119 ++++------ .../worktree-proximity/src/event-support.ts | 3 +- .../src/events/agent/start.tsx | 48 ++-- .../src/events/session/start.tsx | 20 +- .../worktree-proximity/src/events/stop.tsx | 21 +- .../src/events/tool/after.tsx | 21 +- .../src/events/tool/before.tsx | 66 +++--- .../src/mcp/coordinator/tools/status.tsx | 24 +- .../src/providers/agent-topology.ts | 65 +---- examples/worktree-proximity/src/state.ts | 6 + .../tests/route-unit/routes.test.ts | 222 +++++++++++++----- 12 files changed, 363 insertions(+), 322 deletions(-) diff --git a/examples/worktree-proximity/README.md b/examples/worktree-proximity/README.md index 8365f07b5..149422987 100644 --- a/examples/worktree-proximity/README.md +++ b/examples/worktree-proximity/README.md @@ -39,25 +39,33 @@ The application has four planes: - **Providers** — `git-worktree` derives repository, branch, commit, common Git directory, and linked-worktree identity without throwing for expected - degradation. `agent-topology` exposes a read-only durable snapshot. + degradation. `agent-topology` reports that its snapshot is unavailable + before request mounting. - **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 a SQLite state root. Each request opens, - uses, and closes its stores; SQLite supplies cross-process durability and - idempotency without a daemon. + 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. -`WORKTREE_PROXIMITY_STATE_DIR` overrides storage for tests and explicit -deployments. Otherwise state lives at -`/agent-bundle-proximity/`, so linked worktrees share one -durable topology and notice ledger. +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 +providers execute before request state is mounted, so this provider reports +an honest unavailable result and routes read snapshots from +`(await agent()).state.read()` instead. `worktree()` in `src/api.ts` is the issue-mandated custom Promise API over the provider value. A `useWorktree()` React-hook variant is recorded unavailable: -main exposes no client-hook contract for provider values. +the framework exposes no client-hook contract for provider values. ## Actor identity and provenance @@ -80,33 +88,31 @@ rendered as unavailable instead of being replaced with invented evidence. ## Framework primitive wiring -The topology and notice operations are custom APIs composed from public -framework primitives. Generated bundles do not yet mount `(await -agent()).state` or `(await agent()).notices` (issue #233). The application -probes those reserved request handles first and uses them when available, -then falls back to opening the SQLite driver and notice ledger for the current -request. This is application wiring, not a private framework import, and it -can disappear naturally when #233 lands. +Generated route workers mount the extracted `src/state.ts` definition and the +notice ledger into every request scope. `withTopology` 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. -The local notice authorizer admits this repository-scoped demonstration's -actor-addressed publications and deliveries. Recipient matching uses only the -actor axis, so it does not accidentally require a matching session or -worktree axis. +Notice admission runs once per event invocation in the render scope. +Recipient matching uses the actor identity mounted in that request, and +`(await agent()).notices.read()` exposes only deliveries attempted for that +invocation. The coordinator status therefore reports topology facts only and +does not claim a whole-ledger pending count. ## Evidence boundary -The deterministic suite is artifact/contract integration evidence, NOT -commercial-host dispatch proof. Route-unit tests render compiled route -modules through the framework request and document contracts in-process. They -do not prove that Claude, Codex, or another commercial host invokes a hook, -preserves its envelope, or displays projected context in production. - -The later real-child-process journey suite is responsible for process-level -restart and dispatch evidence. The version-1 state design is restart durable, -but this slice makes no claim that the later journey suite has run. +The route-unit suite is in-process real-renderer evidence: it compiles the +manifest, mounts the real state and notice handles, renders the event routes, +and exercises the documented journeys against one shared durable runtime +owner. The multi-process artifact/contract suite is the next slice and has +not run here. Neither level is proof that Claude, Codex, or another commercial +host dispatches a hook, preserves its envelope, or displays projected context +in production. ## External-driver boundary -Version 1 connects NO external adapter and claims none. A real external -adapter must pass the framework state-driver conformance suite before any -“integrated” claim. SQLite is the only durable driver used by this example. +Version 1 connects no external driver adapter and claims none. A future +external adapter must pass the framework state-driver conformance suite +before any “integrated” claim. The generated runtime's SQLite driver is the +only durable driver used by this example. diff --git a/examples/worktree-proximity/src/coordination.ts b/examples/worktree-proximity/src/coordination.ts index 6e6d531f9..4ae8d4d57 100644 --- a/examples/worktree-proximity/src/coordination.ts +++ b/examples/worktree-proximity/src/coordination.ts @@ -1,102 +1,73 @@ -import { resolve, join } from 'node:path'; - import { agent, - available, type AgentStateHandle, - type ObservedSource, } from '@agent-bundle/runtime'; -import { - agentNoticeStateDefinition, - createAgentNoticeLedger, - type AgentNoticeLedgerSnapshot, - type AgentNoticesHandle, -} from '@agent-bundle/runtime/notices'; -import type { AgentStateStore } from '@agent-bundle/runtime/state'; -import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'; +import type { AgentNoticesHandle } from '@agent-bundle/runtime/notices'; -import type { AvailableWorktree } from './api.js'; import { - topologyStateDefinition, type TopologyEvents, type TopologyState, } from './state.js'; export type TopologyAccess = - Pick, 'dispatch' | 'read'>; + Pick, 'dispatch' | 'read'>; -export const stateRootFor = (worktree: AvailableWorktree): string => { - const configured = process.env.WORKTREE_PROXIMITY_STATE_DIR; - return configured === undefined || configured.trim() === '' - ? join(worktree.commonDir, 'agent-bundle-proximity') - : resolve(configured); -}; +export type CapabilityResult = + | { + readonly state: 'available'; + readonly value: T; + } + | { + readonly reason: string; + readonly state: 'unavailable'; + }; export const withTopology = async ( - worktree: AvailableWorktree, operation: (topology: TopologyAccess) => Promise, -): Promise => { +): Promise> => { const context = await agent(); - // Generated bundles do not mount these reserved handles yet (#233). Prefer - // the framework handle when present; otherwise compose the same primitives - // here and keep their lifecycle scoped to this application request. - if (context.state !== undefined) { - return operation(context.state as AgentStateHandle); + if (context.state === undefined) { + return { + reason: 'Topology state unavailable: this request has no mounted state handle.', + state: 'unavailable', + }; } - - const driver = createSqliteStateDriver({ root: stateRootFor(worktree) }); try { - return await operation(await driver.open(topologyStateDefinition)); - } finally { - await driver.close(); + return { + state: 'available', + value: await operation( + context.state as AgentStateHandle, + ), + }; + } catch (error) { + return { + reason: + `Topology state unavailable: ${error instanceof Error ? error.message : String(error)}`, + state: 'unavailable', + }; } }; export const withNotices = async ( - worktree: AvailableWorktree, - actorId: string | undefined, - actorSource: ObservedSource, operation: (notices: AgentNoticesHandle) => Promise, -): Promise => { +): Promise> => { const context = await agent(); - if (context.notices !== undefined) { - return operation(context.notices); + if (context.notices === undefined) { + return { + reason: 'Directed notices unavailable: this request has no mounted notice handle.', + state: 'unavailable', + }; } - - const driver = createSqliteStateDriver({ root: stateRootFor(worktree) }); - let lease: Awaited['openRequest']>> | undefined; - try { - const store = await driver.open(agentNoticeStateDefinition()); - const ledger = createAgentNoticeLedger(store, { - authorize: () => ({ state: 'authorized' }), - }); - lease = await ledger.openRequest({ - invocation: context.invocation, - principal: { - actor: actorId === undefined ? context.actor : available({ id: actorId }, actorSource), - host: context.host, - session: context.session, - workspace: context.workspace, - }, - signal: context.signal, - }); - return await operation(lease.handle); - } finally { - lease?.close(); - await driver.close(); - } -}; - -export const readNoticeLedger = async ( - worktree: AvailableWorktree, -): Promise => { - const driver = createSqliteStateDriver({ root: stateRootFor(worktree) }); try { - const store = await driver.open(agentNoticeStateDefinition()); - return await createAgentNoticeLedger(store, { - authorize: () => ({ state: 'authorized' }), - }).read(); - } finally { - await driver.close(); + return { + state: 'available', + value: await operation(context.notices), + }; + } catch (error) { + return { + reason: + `Directed notices unavailable: ${error instanceof Error ? error.message : String(error)}`, + state: 'unavailable', + }; } }; diff --git a/examples/worktree-proximity/src/event-support.ts b/examples/worktree-proximity/src/event-support.ts index d5bfb35d6..3055781d1 100644 --- a/examples/worktree-proximity/src/event-support.ts +++ b/examples/worktree-proximity/src/event-support.ts @@ -1,7 +1,6 @@ import type { AgentDocumentNode, AgentNoticeDelivery, - ObservedSource, } from '@agent-bundle/runtime'; import type { AvailableWorktree } from './api.js'; @@ -20,7 +19,7 @@ export interface ExtractedIntent { export interface ResolvedActor { readonly id: string; - readonly source: ObservedSource; + readonly source: 'derived' | 'native'; } export const nativeString = ( diff --git a/examples/worktree-proximity/src/events/agent/start.tsx b/examples/worktree-proximity/src/events/agent/start.tsx index 228b6d8e5..0df71385e 100644 --- a/examples/worktree-proximity/src/events/agent/start.tsx +++ b/examples/worktree-proximity/src/events/agent/start.tsx @@ -26,13 +26,11 @@ export default async function AgentStart({ const agentId = nativeString(native, 'agent_id'); const sessionId = nativeString(native, 'session_id'); - const refusal = agentId === undefined - ? 'agent/start omitted native agent_id; refused to fabricate a topology edge' - : sessionId === undefined - ? 'agent/start omitted native session_id; refused to fabricate a topology edge' - : undefined; - if (refusal !== undefined) { - await withTopology(currentWorktree, async (topology) => { + if (agentId === undefined || sessionId === undefined) { + const refusal = agentId === 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', { idempotencyKey: canonical.idempotencyKey, observedAt: canonical.observedAt, @@ -42,22 +40,23 @@ export default async function AgentStart({ idempotencyKey: `${canonical.idempotencyKey}:refusal`, }); }); - const deliveries = await withNotices( - currentWorktree, - undefined, - 'derived', - async (notices) => notices.read(), - ); + const noticeResult = await withNotices(async (notices) => notices.read()); + const contexts = [ + ...(topologyResult.state === 'unavailable' ? [topologyResult.reason] : []), + ...(noticeResult.state === 'available' + ? deliveryContexts(noticeResult.value) + : [noticeResult.reason]), + ]; return ( {`Parent identity unavailable; ${refusal}.`} - {deliveryContexts(deliveries).map((context) => + {contexts.map((context) => {context})} ); } - await withTopology(currentWorktree, async (topology) => { + const topologyResult = await withTopology(async (topology) => { await topology.dispatch('actorObserved', { id: agentId, kind: 'child', @@ -78,15 +77,20 @@ export default async function AgentStart({ idempotencyKey: `${canonical.idempotencyKey}:worktree`, }); }); - const deliveries = await withNotices( - currentWorktree, - agentId, - 'native', - async (notices) => notices.read(), - ); + if (topologyResult.state === 'unavailable') { + return ( + + {topologyResult.reason} + + ); + } + const noticeResult = await withNotices(async (notices) => notices.read()); + const contexts = noticeResult.state === 'available' + ? deliveryContexts(noticeResult.value) + : [noticeResult.reason]; return ( - {deliveryContexts(deliveries).map((context) => + {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 b93507ce4..80392a9d1 100644 --- a/examples/worktree-proximity/src/events/session/start.tsx +++ b/examples/worktree-proximity/src/events/session/start.tsx @@ -33,7 +33,7 @@ export default async function SessionStart({ } const actorId = `session:${sessionId}`; - await withTopology(currentWorktree, async (topology) => { + const topologyResult = await withTopology(async (topology) => { await topology.dispatch('actorObserved', { id: actorId, kind: 'root', @@ -50,13 +50,17 @@ export default async function SessionStart({ idempotencyKey: `${canonical.idempotencyKey}:worktree`, }); }); - const deliveries = await withNotices( - currentWorktree, - actorId, - 'native', - async (notices) => notices.read(), - ); - const contexts = deliveryContexts(deliveries); + if (topologyResult.state === 'unavailable') { + return ( + + {topologyResult.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/stop.tsx b/examples/worktree-proximity/src/events/stop.tsx index 5537b7d71..20ef7e52d 100644 --- a/examples/worktree-proximity/src/events/stop.tsx +++ b/examples/worktree-proximity/src/events/stop.tsx @@ -29,7 +29,7 @@ export default async function Stop({ ); } const nativeActorId = nativeString(native, 'agent_id'); - const actor = await withTopology(currentWorktree, async (topology): Promise => { + const topologyResult = await withTopology(async (topology): Promise => { const resolved = nativeActorId === undefined ? (await actorForWorktree(topology, currentWorktree, canonical)).actor : { id: nativeActorId, source: 'native' as const }; @@ -41,15 +41,20 @@ export default async function Stop({ }); return resolved; }); - const deliveries = await withNotices( - currentWorktree, - actor.id, - actor.source, - async (notices) => notices.read(), - ); + if (topologyResult.state === 'unavailable') { + return ( + + {topologyResult.reason} + + ); + } + const noticeResult = await withNotices(async (notices) => notices.read()); + const contexts = noticeResult.state === 'available' + ? deliveryContexts(noticeResult.value) + : [noticeResult.reason]; return ( - {deliveryContexts(deliveries).map((context) => + {contexts.map((context) => {context})} ); diff --git a/examples/worktree-proximity/src/events/tool/after.tsx b/examples/worktree-proximity/src/events/tool/after.tsx index b518dc58c..72c8bb3c4 100644 --- a/examples/worktree-proximity/src/events/tool/after.tsx +++ b/examples/worktree-proximity/src/events/tool/after.tsx @@ -22,7 +22,7 @@ export default async function AfterTool({ ); } - const actor = await withTopology(currentWorktree, async (topology) => { + const topologyResult = await withTopology(async (topology) => { const resolved = await actorForWorktree(topology, currentWorktree, canonical); await topology.dispatch('intentRecorded', { actorId: resolved.actor.id, @@ -40,15 +40,20 @@ export default async function AfterTool({ }); return resolved.actor; }); - const deliveries = await withNotices( - currentWorktree, - actor.id, - actor.source, - async (notices) => notices.read(), - ); + if (topologyResult.state === 'unavailable') { + return ( + + {topologyResult.reason} + + ); + } + const noticeResult = await withNotices(async (notices) => notices.read()); + const contexts = noticeResult.state === 'available' + ? deliveryContexts(noticeResult.value) + : [noticeResult.reason]; return ( - {deliveryContexts(deliveries).map((context) => + {contexts.map((context) => {context})} ); diff --git a/examples/worktree-proximity/src/events/tool/before.tsx b/examples/worktree-proximity/src/events/tool/before.tsx index 1e691c738..e7cf8632e 100644 --- a/examples/worktree-proximity/src/events/tool/before.tsx +++ b/examples/worktree-proximity/src/events/tool/before.tsx @@ -1,4 +1,4 @@ -import { Agent } from '@agent-bundle/runtime'; +import { Agent, type JsonValue } from '@agent-bundle/runtime'; import type { AgentEventRouteProps } from 'agent-bundle'; import React from 'react'; @@ -29,7 +29,7 @@ export default async function BeforeTool({ ); } const intent = extractIntent(native); - const resolution = await withTopology(currentWorktree, async (topology) => { + const topologyResult = await withTopology(async (topology) => { const { actor } = await actorForWorktree(topology, currentWorktree, canonical); const committed = await topology.dispatch('intentRecorded', { actorId: actor.id, @@ -54,39 +54,45 @@ export default async function BeforeTool({ }), }; }); + if (topologyResult.state === 'unavailable') { + return ( + + {topologyResult.reason} + + ); + } + const resolution = topologyResult.value; - const deliveryAndPublication = await withNotices( - currentWorktree, - resolution.actor.id, - resolution.actor.source, - async (notices) => { - const deliveries = await notices.read(); - for (const [index, conflict] of resolution.conflicts.entries()) { - await notices.publish({ - content: { - root: { - kind: 'text', - text: conflict.summary, - }, - status: 'success', - version: 1, + const noticeResult = await withNotices(async (notices) => { + const deliveries = await notices.read(); + for (const [index, conflict] of resolution.conflicts.entries()) { + await notices.publish({ + content: { + root: { + kind: 'text', + text: conflict.summary, }, - dedupeKey: `proximity:${resolution.actor.id}:${conflict.actorId}:${conflict.summary}`, - priority: 'high', - recipient: { - actor: { id: conflict.actorId }, - }, - }, { - idempotencyKey: `${canonical.idempotencyKey}:notice:${String(index)}`, - }); - } - return deliveryContexts(deliveries); - }, - ); + status: 'success', + version: 1, + }, + dedupeKey: `proximity:${resolution.actor.id}:${conflict.actorId}:${conflict.summary}`, + priority: 'high', + recipient: { + actor: { id: conflict.actorId }, + }, + }, { + idempotencyKey: `${canonical.idempotencyKey}:notice:${String(index)}`, + }); + } + return deliveryContexts(deliveries); + }); + const deliveryAndPublication = noticeResult.state === 'available' + ? noticeResult.value + : [noticeResult.reason]; const warnings = resolution.conflicts.map((conflict) => `Proximity warning for ${resolution.actor.id}: ${conflict.summary}`); const reason = warnings.join(' '); - const value = reason === '' + const value: JsonValue = reason === '' ? { outcome: 'continue' as const } : { outcome: 'continue' as const, reason }; diff --git a/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx b/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx index b51223539..16ee525da 100644 --- a/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx +++ b/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx @@ -3,17 +3,12 @@ import type { ToolConfig, ToolRouteProps } from 'agent-bundle'; import React from 'react'; import { z } from 'zod'; -import { worktree } from '../../../api.js'; -import { - readNoticeLedger, - stateRootFor, - withTopology, -} from '../../../coordination.js'; +import { withTopology } from '../../../coordination.js'; import { ActorSchema } from '../../../state.js'; export const config = { annotations: { readOnlyHint: true }, - description: 'Show the durable worktree topology, active intents, refusals, and pending directed notices.', + description: 'Show the mounted durable worktree topology, active intents, and refusals.', } satisfies ToolConfig; export const inputSchema = z @@ -26,11 +21,9 @@ export const resultSchema = z .object({ activeActivities: z.number().int().nonnegative(), actors: z.array(ActorSchema), - pendingNotices: z.number().int().nonnegative(), reason: z.string().optional(), refusals: z.number().int().nonnegative(), state: z.enum(['available', 'unavailable']), - stateRoot: z.string().optional(), }) .strict(); @@ -39,20 +32,18 @@ type StatusResult = z.output; export default async function Status({ input, }: ToolRouteProps) { - const currentWorktree = await worktree(); + const topologyResult = await withTopology(async (store) => (await store.read()).state); let result: StatusResult; - if (currentWorktree.state === 'unavailable') { + if (topologyResult.state === 'unavailable') { result = { activeActivities: 0, actors: [], - pendingNotices: 0, - reason: currentWorktree.reason, + reason: topologyResult.reason, refusals: 0, state: 'unavailable', }; } else { - const topology = await withTopology(currentWorktree, async (store) => (await store.read()).state); - const notices = await readNoticeLedger(currentWorktree); + const topology = topologyResult.value; const actors = input.actorId === undefined ? topology.actors : topology.actors.filter((actor) => actor.id === input.actorId); @@ -64,10 +55,8 @@ export default async function Status({ && (activity.paths.length > 0 || activity.dependencies.length > 0), ).length, actors, - pendingNotices: notices.notices.filter((notice) => notice.state === 'pending').length, refusals: topology.refusals.length, state: 'available', - stateRoot: stateRootFor(currentWorktree), }; } @@ -77,7 +66,6 @@ export default async function Status({ '', `- Actors: ${String(result.actors.length)}`, `- Active activities: ${String(result.activeActivities)}`, - `- Pending notices: ${String(result.pendingNotices)}`, `- Refused edges: ${String(result.refusals)}`, ].join('\n') : `# Worktree proximity status\n\nUnavailable: ${result.reason ?? 'unknown reason'}`; diff --git a/examples/worktree-proximity/src/providers/agent-topology.ts b/examples/worktree-proximity/src/providers/agent-topology.ts index 45b877ec4..31c47432f 100644 --- a/examples/worktree-proximity/src/providers/agent-topology.ts +++ b/examples/worktree-proximity/src/providers/agent-topology.ts @@ -1,59 +1,12 @@ -import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'; - -import { stateRootFor } from '../coordination.js'; -import { - topologyStateDefinition, - type TopologyState, -} from '../state.js'; -import gitWorktreeProvider from './git-worktree.js'; - -interface ProviderContext { - readonly invocation: { - readonly kind: string; - readonly props: Readonly>; - }; - readonly signal: AbortSignal; +export interface AgentTopologyProviderValue { + readonly reason: string; + readonly state: 'unavailable'; } -export type AgentTopologyProviderValue = - | { - readonly snapshot: TopologyState; - readonly state: 'available'; - readonly stateRoot: string; - } - | { - readonly reason: string; - readonly state: 'unavailable'; - }; - -export default async function agentTopologyProvider( - context: ProviderContext, -): Promise { - const worktree = await gitWorktreeProvider(context); - if (worktree.state === 'unavailable') { - return { - reason: worktree.reason, - state: 'unavailable', - }; - } - - const stateRoot = stateRootFor(worktree); - const driver = createSqliteStateDriver({ root: stateRoot }); - try { - const store = await driver.open(topologyStateDefinition); - const snapshot = await store.read({ signal: context.signal }); - return { - snapshot: snapshot.state, - state: 'available', - stateRoot, - }; - } catch (error) { - return { - reason: - `Topology state is unavailable: ${error instanceof Error ? error.message : String(error)}`, - state: 'unavailable', - }; - } finally { - await driver.close(); - } +export default function agentTopologyProvider(): AgentTopologyProviderValue { + return { + reason: + 'Topology snapshots are available only from the mounted request state handle; providers execute before that handle is mounted.', + state: 'unavailable', + }; } diff --git a/examples/worktree-proximity/src/state.ts b/examples/worktree-proximity/src/state.ts index f7b53818b..0f51e26af 100644 --- a/examples/worktree-proximity/src/state.ts +++ b/examples/worktree-proximity/src/state.ts @@ -164,3 +164,9 @@ export const topologyStateDefinition = defineState({ schema: TopologyStateSchema, version: 1, }); + +export default defineState({ + ...topologyStateDefinition, + id: 'worktree-proximity/topology', + lifetime: 'workspace-durable', +}); diff --git a/examples/worktree-proximity/tests/route-unit/routes.test.ts b/examples/worktree-proximity/tests/route-unit/routes.test.ts index 90a7f7297..30da9c34d 100644 --- a/examples/worktree-proximity/tests/route-unit/routes.test.ts +++ b/examples/worktree-proximity/tests/route-unit/routes.test.ts @@ -4,11 +4,19 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from '@rstest/core'; import { available } from '@agent-bundle/runtime'; -import { agentNoticeStateDefinition } from '@agent-bundle/runtime/notices'; +import { + createGeneratedRuntimeState, + type GeneratedRuntimeState, +} from '@agent-bundle/runtime/mount'; import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'; import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test'; -import { topologyStateDefinition } from '../../src/state.js'; +import BeforeTool from '../../src/events/tool/before.js'; +import { + topologyStateDefinition, + type TopologyEvents, + type TopologyState, +} from '../../src/state.js'; const manifest = testManifest(); @@ -19,7 +27,7 @@ const worktrees = { } as const; let stateRoot: string; -let previousStateRoot: string | undefined; +let runtimeState: GeneratedRuntimeState; let sequence = 0; const provider = (root: string) => ({ @@ -52,27 +60,50 @@ const eventInput = ( native, }); -const renderEvent = async ( +const renderEventInput = async ( + route: string, + input: ReturnType, + id: string, + worktreeRoot: string, + actorId?: string, +) => { + const bindings = await runtimeState.requestBindings(); + try { + return await renderRoute(route, { + context: { + actor: actorId === undefined ? undefined : available({ id: actorId }, 'native'), + host: available({ name: 'claude' }, 'native'), + invocation: { + id: `invocation:${id}`, + startedAt: `2026-09-01T20:01:${String(sequence).padStart(2, '0')}.000Z`, + }, + noticeLedger: bindings.noticeLedger, + providers: { gitWorktree: provider(worktreeRoot) }, + session: available({ sessionId: 'root-session' }, 'native'), + state: bindings.state, + workspace: available({ root: '/repo' }, 'native'), + }, + input, + }); + } finally { + await bindings.close(); + } +}; + +const renderEvent = ( route: string, event: Parameters[0], native: Record, id: string, worktreeRoot: string, actorId?: string, -) => renderRoute(route, { - context: { - actor: actorId === undefined ? undefined : available({ id: actorId }, 'native'), - host: available({ name: 'claude' }, 'native'), - invocation: { - id: `invocation:${id}`, - startedAt: `2026-09-01T20:01:${String(sequence).padStart(2, '0')}.000Z`, - }, - providers: { gitWorktree: provider(worktreeRoot) }, - session: available({ sessionId: 'root-session' }, 'native'), - workspace: available({ root: '/repo' }, 'native'), - }, - input: eventInput(event, native, id), -}); +) => renderEventInput( + route, + eventInput(event, native, id), + id, + worktreeRoot, + actorId, +); const bindActors = async (): Promise => { await renderEvent( @@ -136,14 +167,15 @@ const recordIntent = ( beforeEach(async () => { stateRoot = await mkdtemp(join(tmpdir(), 'worktree-proximity-route-unit-')); - previousStateRoot = process.env.WORKTREE_PROXIMITY_STATE_DIR; - process.env.WORKTREE_PROXIMITY_STATE_DIR = stateRoot; + runtimeState = createGeneratedRuntimeState({ + definition: topologyStateDefinition, + driver: createSqliteStateDriver({ root: stateRoot }), + }); sequence = 0; }); afterEach(async () => { - if (previousStateRoot === undefined) delete process.env.WORKTREE_PROXIMITY_STATE_DIR; - else process.env.WORKTREE_PROXIMITY_STATE_DIR = previousStateRoot; + await runtimeState.close(); await rm(stateRoot, { force: true, recursive: true }); }); @@ -162,57 +194,63 @@ it('compiles the complete shared-runtime route surface', () => { describe('worktree proximity journeys', () => { it('does not warn when active paths and dependencies do not overlap (journey 3)', async () => { await bindActors(); - await recordIntent('agent-b', worktrees.b, 'src/catalog.ts', 'intent:b', 'deps:zod'); - const rendered = await recordIntent('agent-a', worktrees.a, 'src/player.ts', 'intent:a', 'deps:react'); + const first = await recordIntent('agent-a', worktrees.a, 'src/player.ts', 'intent:a', 'deps:react'); + const rendered = await recordIntent('agent-b', worktrees.b, 'src/catalog.ts', 'intent:b', 'deps:zod'); + expectDocument(first).toHaveStatus('success').toHaveNodeKinds(['result']); + expect(first.document.value).toEqual({ outcome: 'continue' }); expectDocument(rendered).toHaveStatus('success').toHaveNodeKinds(['result']); expect(rendered.document.value).toEqual({ outcome: 'continue' }); }); it('warns without denying and publishes a pending directed notice (journeys 4 and 5)', async () => { await bindActors(); - await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); - const rendered = await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); + const first = await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); + const rendered = await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); + expectDocument(first).toHaveStatus('success').toHaveNodeKinds(['result']); + expect(first.document.value).toEqual({ outcome: 'continue' }); expectDocument(rendered) .toHaveStatus('success') .toContainContext('Proximity warning') .toContainContext('src/shared.ts'); - expect(rendered.document.value).toMatchObject({ outcome: 'continue' }); + expect(rendered.document.value).toMatchObject({ + outcome: 'continue', + reason: expect.stringContaining('src/shared.ts'), + }); - const driver = createSqliteStateDriver({ root: stateRoot }); + const bindings = await runtimeState.requestBindings(); try { - const store = await driver.open(agentNoticeStateDefinition()); - const snapshot = await store.read(); - expect(snapshot.state.notices).toEqual([ + const snapshot = await bindings.noticeLedger.read(); + expect(snapshot.notices).toEqual([ expect.objectContaining({ - recipient: { actor: { id: 'agent-b' } }, + recipient: { actor: { id: 'agent-a' } }, state: 'pending', }), ]); } finally { - await driver.close(); + await bindings.close(); } }); it('attempts and surfaces a notice on the recipient next event (journey 6)', async () => { await bindActors(); - await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); + await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); const delivered = await renderEvent( 'event:tool/after', 'tool/after', { - cwd: worktrees.b, + cwd: worktrees.a, hook_event_name: 'PostToolUse', session_id: 'root-session', tool_input: { file_path: 'src/shared.ts' }, tool_name: 'Edit', }, - 'intent:b:after', - worktrees.b, - 'agent-b', + 'intent:a:after', + worktrees.a, + 'agent-a', ); expectDocument(delivered) @@ -220,32 +258,52 @@ describe('worktree proximity journeys', () => { .toContainContext('Directed proximity notice') .toContainContext('src/shared.ts'); - const driver = createSqliteStateDriver({ root: stateRoot }); + const bindings = await runtimeState.requestBindings(); try { - const store = await driver.open(agentNoticeStateDefinition()); - const snapshot = await store.read(); - expect(snapshot.state.notices[0]).toMatchObject({ - attempts: [expect.objectContaining({ invocationId: 'invocation:intent:b:after' })], + const snapshot = await bindings.noticeLedger.read(); + expect(snapshot.notices[0]).toMatchObject({ + attempts: [expect.objectContaining({ invocationId: 'invocation:intent:a:after' })], state: 'attempted', }); } finally { - await driver.close(); + await bindings.close(); } }); it('deduplicates a repeated native intent envelope (journey 7)', async () => { await bindActors(); - await recordIntent('agent-a', worktrees.a, 'src/player.ts', 'intent:replayed'); - await recordIntent('agent-a', worktrees.a, 'src/player.ts', 'intent:replayed'); + const replayed = eventInput( + 'tool/before', + { + cwd: worktrees.a, + hook_event_name: 'PreToolUse', + session_id: 'root-session', + tool_input: { file_path: 'src/player.ts' }, + tool_name: 'Edit', + }, + 'intent:replayed', + ); + await renderEventInput( + 'event:tool/before', + replayed, + 'intent:replayed', + worktrees.a, + 'agent-a', + ); + await renderEventInput( + 'event:tool/before', + replayed, + 'intent:replayed', + worktrees.a, + 'agent-a', + ); - const driver = createSqliteStateDriver({ root: stateRoot }); + const bindings = await runtimeState.requestBindings(); try { - const store = await driver.open(topologyStateDefinition); - const snapshot = await store.read(); + const snapshot = await bindings.state.read(); expect(snapshot.state.activities.filter((activity) => activity.actorId === 'agent-a')).toHaveLength(1); - expect(snapshot.revision).toBe(7); } finally { - await driver.close(); + await bindings.close(); } }); @@ -268,10 +326,9 @@ describe('worktree proximity journeys', () => { .toContainContext('Parent identity unavailable') .toContainContext('refused to fabricate'); - const driver = createSqliteStateDriver({ root: stateRoot }); + const bindings = await runtimeState.requestBindings(); try { - const store = await driver.open(topologyStateDefinition); - const snapshot = await store.read(); + const snapshot = await bindings.state.read(); expect(snapshot.state.actors).toEqual([]); expect(snapshot.state.refusals).toEqual([ expect.objectContaining({ @@ -279,21 +336,29 @@ describe('worktree proximity journeys', () => { }), ]); } finally { - await driver.close(); + await bindings.close(); } }); - it('renders the coordinator status with topology and pending notice counts', async () => { + it('renders the coordinator status from mounted topology state', async () => { await bindActors(); - await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); + await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); - const rendered = await renderRoute('tool:coordinator/status', { - context: { - providers: { gitWorktree: provider(worktrees.root) }, - }, - input: {}, - }); + const bindings = await runtimeState.requestBindings(); + let rendered: Awaited>; + try { + rendered = await renderRoute('tool:coordinator/status', { + context: { + noticeLedger: bindings.noticeLedger, + providers: { gitWorktree: provider(worktrees.root) }, + state: bindings.state, + }, + input: {}, + }); + } finally { + await bindings.close(); + } expectDocument(rendered) .toHaveStatus('success') @@ -304,8 +369,37 @@ describe('worktree proximity journeys', () => { expect.objectContaining({ id: 'agent-a', worktreeRoot: worktrees.a }), expect.objectContaining({ id: 'agent-b', worktreeRoot: worktrees.b }), ]), - pendingNotices: 1, refusals: 0, }); }); + + it('renders state unavailability when an event module has no mounted handle', async () => { + const rendered = await renderRoute({ default: BeforeTool }, { + context: { + actor: available({ id: 'agent-a' }, 'native'), + providers: { gitWorktree: provider(worktrees.a) }, + }, + input: eventInput( + 'tool/before', + { + cwd: worktrees.a, + hook_event_name: 'PreToolUse', + session_id: 'root-session', + tool_input: { file_path: 'src/shared.ts' }, + tool_name: 'Edit', + }, + 'intent:unmounted', + ), + kind: 'event-route', + routeId: 'event:tool/before', + }); + + expectDocument(rendered) + .toHaveStatus('success') + .toContainContext('state unavailable'); + expect(rendered.document.value).toMatchObject({ + outcome: 'continue', + reason: expect.stringContaining('state unavailable'), + }); + }); });