From 7d7b1e758556abfa5ee8eeaf46ec6fa8ebfbf3cd Mon Sep 17 00:00:00 2001 From: Thomas Mustier <6326440+tmustier@users.noreply.github.com> Date: Mon, 25 May 2026 10:44:35 +0100 Subject: [PATCH] feat(pinet): add worker subtree supervision staging --- broker-core/agent-messaging.ts | 58 ++++- broker-core/maintenance.ts | 11 +- broker-core/schema.ts | 329 +++++++++++++++++++++++- broker-core/types.ts | 10 + plans/761-pinet-worker-subtrees.md | 21 ++ slack-bridge/README.md | 4 +- slack-bridge/broker/helpers.test.ts | 58 +++++ slack-bridge/broker/integration.test.ts | 32 +++ slack-bridge/helpers.ts | 106 +++++--- slack-bridge/pinet-mesh-ops.ts | 19 ++ slack-bridge/pinet-tools.test.ts | 41 +++ slack-bridge/pinet-tools.ts | 142 +++++++++- slack-bridge/runtime-agent-context.ts | 19 ++ 13 files changed, 798 insertions(+), 52 deletions(-) create mode 100644 plans/761-pinet-worker-subtrees.md diff --git a/broker-core/agent-messaging.ts b/broker-core/agent-messaging.ts index bac8e02..fb40c09 100644 --- a/broker-core/agent-messaging.ts +++ b/broker-core/agent-messaging.ts @@ -71,6 +71,7 @@ export interface DirectAgentDispatchInput { target: string; body: string; metadata?: Record; + trustedBrokerAgentId?: string; } export interface BroadcastAgentDispatchInput { @@ -245,6 +246,43 @@ export function resolveDirectAgentTarget(agents: AgentInfo[], target: string): A ); } +function isDescendantOf(agents: AgentInfo[], descendantId: string, ancestorId: string): boolean { + let current = agents.find((agent) => agent.id === descendantId) ?? null; + const seen = new Set(); + while (current?.parentAgentId) { + if (current.parentAgentId === ancestorId) return true; + if (seen.has(current.parentAgentId)) return false; + seen.add(current.parentAgentId); + current = agents.find((agent) => agent.id === current?.parentAgentId) ?? null; + } + return false; +} + +function canDispatchDirectAgentMessage( + agents: AgentInfo[], + sender: AgentInfo | null, + target: AgentInfo, + metadata?: Record, +): boolean { + if (!sender) return false; + if ( + !target.parentAgentId && + target.supervisionState !== "supervised" && + target.supervisionState !== "orphaned" && + target.supervisionState !== "stopping" + ) { + return true; + } + if (sender.id === metadata?.trustedBrokerAgentId) { + return metadata.emergency === true || metadata.targetScope === "subtree"; + } + if (target.parentAgentId === sender.id) return true; + if (sender.parentAgentId === target.id) return true; + if (isDescendantOf(agents, target.id, sender.id)) return true; + if (isDescendantOf(agents, sender.id, target.id)) return true; + return false; +} + export function resolveBroadcastTargets( agents: AgentInfo[], senderAgentId: string, @@ -252,6 +290,13 @@ export function resolveBroadcastTargets( ): AgentInfo[] { return agents .filter((agent) => agent.id !== senderAgentId) + .filter( + (agent) => + !agent.parentAgentId && + agent.supervisionState !== "supervised" && + agent.supervisionState !== "orphaned" && + agent.supervisionState !== "stopping", + ) .filter((agent) => agentSubscribesToBroadcastChannel(agent, channel)) .sort((left, right) => left.name.localeCompare(right.name)); } @@ -261,10 +306,21 @@ export function dispatchDirectAgentMessage( input: DirectAgentDispatchInput, onDispatch?: AgentDispatchCallback, ): DirectAgentDispatchResult { - const target = resolveDirectAgentTarget(storage.getAgents(), input.target); + const agents = storage.getAgents(); + const target = resolveDirectAgentTarget(agents, input.target); if (!target) { throw new Error(`Agent not found: ${input.target}`); } + const sender = agents.find((agent) => agent.id === input.senderAgentId) ?? null; + const policyMetadata = { + ...(input.metadata ?? {}), + ...(input.trustedBrokerAgentId ? { trustedBrokerAgentId: input.trustedBrokerAgentId } : {}), + }; + if (!canDispatchDirectAgentMessage(agents, sender, target, policyMetadata)) { + throw new Error( + `Agent ${input.senderAgentId} cannot message supervised agent ${target.id} without parent/subtree visibility or an explicit broker emergency override.`, + ); + } const resolvedTarget: AgentDispatchTarget = { id: target.id, name: target.name }; const metadata = buildAgentMessageMetadata(input.senderAgentName, input.body, input.metadata); diff --git a/broker-core/maintenance.ts b/broker-core/maintenance.ts index 63927da..e17d907 100644 --- a/broker-core/maintenance.ts +++ b/broker-core/maintenance.ts @@ -97,7 +97,14 @@ export function runBrokerMaintenancePass( const agents = db .getAgents() .filter((agent) => agent.id !== brokerAgentId) - .filter((agent) => agent.metadata?.role !== "broker"); + .filter((agent) => agent.metadata?.role !== "broker") + .filter( + (agent) => + !agent.parentAgentId && + agent.supervisionState !== "supervised" && + agent.supervisionState !== "orphaned" && + agent.supervisionState !== "stopping", + ); const agentLoads = agents.map((agent) => ({ agent, @@ -123,7 +130,7 @@ export function runBrokerMaintenancePass( } const preferredAgent = backlog.preferredAgentId - ? (agents.find((agent) => agent.id === backlog.preferredAgentId) ?? null) + ? (db.getAgents().find((agent) => agent.id === backlog.preferredAgentId) ?? null) : null; if (backlog.preferredAgentId && !preferredAgent) { const knownPreferredAgent = db.getAgentById(backlog.preferredAgentId); diff --git a/broker-core/schema.ts b/broker-core/schema.ts index 75595a6..0051c24 100644 --- a/broker-core/schema.ts +++ b/broker-core/schema.ts @@ -7,6 +7,7 @@ import { getDefaultDbPath } from "./paths.js"; import type { PinetMailClass } from "./mail-classification.js"; import type { AgentInfo, + AgentSupervisionState, ThreadInfo, BrokerMessage, InboxEntry, @@ -70,6 +71,14 @@ interface AgentRow { last_heartbeat: string; metadata: string | null; status: string; + parent_agent_id: string | null; + root_agent_id: string | null; + tree_depth: number | null; + spawned_by_agent_id: string | null; + supervision_state: string | null; + launch_id: string | null; + subtree_role: string | null; + lane_id: string | null; disconnected_at: string | null; resumable_until: string | null; idle_since: string | null; @@ -198,6 +207,14 @@ function rowToAgent(row: AgentRow): AgentInfo { lastHeartbeat: row.last_heartbeat, metadata: row.metadata ? (JSON.parse(row.metadata) as Record) : null, status: row.status === "working" ? "working" : "idle", + parentAgentId: row.parent_agent_id, + rootAgentId: row.root_agent_id, + treeDepth: row.tree_depth ?? 0, + spawnedByAgentId: row.spawned_by_agent_id, + supervisionState: normalizeAgentSupervisionState(row.supervision_state), + launchId: row.launch_id, + subtreeRole: row.subtree_role, + laneId: row.lane_id, disconnectedAt: row.disconnected_at, resumableUntil: row.resumable_until, idleSince: row.idle_since, @@ -462,6 +479,32 @@ function parseMetadataJson(value: string | null): Record | null } } +const AGENT_SUPERVISION_STATES = new Set([ + "root", + "supervised", + "orphaned", + "stopping", +]); + +function normalizeAgentSupervisionState(value: unknown): AgentSupervisionState { + return typeof value === "string" && AGENT_SUPERVISION_STATES.has(value as AgentSupervisionState) + ? (value as AgentSupervisionState) + : "root"; +} + +function getOptionalMetadataString( + metadata: Record | undefined, + keys: string[], +): string | null { + for (const key of keys) { + const value = metadata?.[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return null; +} + function rowToPinetLaneParticipant(row: PinetLaneParticipantRow): PinetLaneParticipantInfo { return { laneId: row.lane_id, @@ -634,7 +677,7 @@ export function defaultDbPath(): string { export const DEFAULT_RESUMABLE_WINDOW_MS = 15_000; export const DEFAULT_DISCONNECTED_PURGE_GRACE_MS = 60 * 60_000; -export const CURRENT_BROKER_SCHEMA_VERSION = 17; +export const CURRENT_BROKER_SCHEMA_VERSION = 18; const REQUIRED_AGENT_LIFECYCLE_COLUMNS = [ "stable_id", @@ -834,6 +877,53 @@ function addObservabilityColumns(db: DatabaseSync): void { `); } +function addAgentHierarchyColumns(db: DatabaseSync): void { + ensureColumn( + db, + "agents", + "parent_agent_id", + "ALTER TABLE agents ADD COLUMN parent_agent_id TEXT", + ); + ensureColumn(db, "agents", "root_agent_id", "ALTER TABLE agents ADD COLUMN root_agent_id TEXT"); + ensureColumn( + db, + "agents", + "tree_depth", + "ALTER TABLE agents ADD COLUMN tree_depth INTEGER NOT NULL DEFAULT 0", + ); + ensureColumn( + db, + "agents", + "spawned_by_agent_id", + "ALTER TABLE agents ADD COLUMN spawned_by_agent_id TEXT", + ); + ensureColumn( + db, + "agents", + "supervision_state", + "ALTER TABLE agents ADD COLUMN supervision_state TEXT NOT NULL DEFAULT 'root'", + ); + ensureColumn(db, "agents", "launch_id", "ALTER TABLE agents ADD COLUMN launch_id TEXT"); + ensureColumn(db, "agents", "subtree_role", "ALTER TABLE agents ADD COLUMN subtree_role TEXT"); + ensureColumn(db, "agents", "lane_id", "ALTER TABLE agents ADD COLUMN lane_id TEXT"); + + db.exec(` + UPDATE agents + SET tree_depth = COALESCE(tree_depth, 0), + supervision_state = COALESCE(supervision_state, 'root') + WHERE tree_depth IS NULL OR supervision_state IS NULL; + + CREATE INDEX IF NOT EXISTS idx_agents_parent_agent_id + ON agents(parent_agent_id); + CREATE INDEX IF NOT EXISTS idx_agents_root_agent_id + ON agents(root_agent_id); + CREATE INDEX IF NOT EXISTS idx_agents_supervision_state + ON agents(supervision_state, parent_agent_id); + CREATE INDEX IF NOT EXISTS idx_agents_lane_id + ON agents(lane_id); + `); +} + function addThreadOwnershipBindingColumn(db: DatabaseSync): void { createCoreTables(db); ensureColumn(db, "threads", "owner_binding", "ALTER TABLE threads ADD COLUMN owner_binding TEXT"); @@ -1388,6 +1478,9 @@ function runSchemaMigrations(db: DatabaseSync): void { case 17: migrateTaskAssignmentsToRepoScopedTracking(db); break; + case 18: + addAgentHierarchyColumns(db); + break; default: throw new Error(`Unsupported broker schema migration target: ${nextVersion}`); } @@ -1507,15 +1600,18 @@ export class BrokerDB implements BrokerDBInterface { ? (JSON.parse(existingRow.metadata) as Record) : undefined); const meta = finalMetadata ? JSON.stringify(finalMetadata) : null; + const hierarchy = this.resolveAgentHierarchy(agentId, finalMetadata, existingRow); db.prepare( `INSERT INTO agents ( id, stable_id, name, emoji, pid, connected_at, last_seen, last_heartbeat, - metadata, status, disconnected_at, resumable_until, - idle_since, last_activity + metadata, status, + parent_agent_id, root_agent_id, tree_depth, spawned_by_agent_id, + supervision_state, launch_id, subtree_role, lane_id, + disconnected_at, resumable_until, idle_since, last_activity ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'idle', NULL, NULL, ?, NULL) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'idle', ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, NULL) ON CONFLICT(id) DO UPDATE SET stable_id = COALESCE(excluded.stable_id, agents.stable_id), name = excluded.name, @@ -1526,14 +1622,42 @@ export class BrokerDB implements BrokerDBInterface { last_heartbeat = excluded.last_heartbeat, metadata = excluded.metadata, status = 'idle', + parent_agent_id = excluded.parent_agent_id, + root_agent_id = excluded.root_agent_id, + tree_depth = excluded.tree_depth, + spawned_by_agent_id = excluded.spawned_by_agent_id, + supervision_state = excluded.supervision_state, + launch_id = excluded.launch_id, + subtree_role = excluded.subtree_role, + lane_id = excluded.lane_id, disconnected_at = NULL, resumable_until = NULL, idle_since = excluded.idle_since, last_activity = NULL`, - ).run(agentId, persistedStableId, finalName, finalEmoji, pid, now, now, now, meta, now); + ).run( + agentId, + persistedStableId, + finalName, + finalEmoji, + pid, + now, + now, + now, + meta, + hierarchy.parentAgentId, + hierarchy.rootAgentId, + hierarchy.treeDepth, + hierarchy.spawnedByAgentId, + hierarchy.supervisionState, + hierarchy.launchId, + hierarchy.subtreeRole, + hierarchy.laneId, + now, + ); return { id: agentId, + stableId: persistedStableId, name: finalName, emoji: finalEmoji, pid, @@ -1542,16 +1666,93 @@ export class BrokerDB implements BrokerDBInterface { lastHeartbeat: now, metadata: finalMetadata ?? null, status: "idle" as const, + parentAgentId: hierarchy.parentAgentId, + rootAgentId: hierarchy.rootAgentId, + treeDepth: hierarchy.treeDepth, + spawnedByAgentId: hierarchy.spawnedByAgentId, + supervisionState: hierarchy.supervisionState, + launchId: hierarchy.launchId, + subtreeRole: hierarchy.subtreeRole, + laneId: hierarchy.laneId, idleSince: now, lastActivity: null, }; } + private resolveAgentHierarchy( + agentId: string, + metadata: Record | undefined, + existingRow: AgentRow | null | undefined, + ): { + parentAgentId: string | null; + rootAgentId: string | null; + treeDepth: number; + spawnedByAgentId: string | null; + supervisionState: AgentSupervisionState; + launchId: string | null; + subtreeRole: string | null; + laneId: string | null; + } { + const requestedParentId = getOptionalMetadataString(metadata, [ + "parentAgentId", + "pinetParentAgentId", + ]); + const parentId = requestedParentId ?? existingRow?.parent_agent_id ?? null; + const parent = parentId ? this.getAgentById(parentId) : null; + + if (parentId && (!parent || parent.disconnectedAt)) { + throw new Error(`Cannot register supervised Pinet agent; parent ${parentId} is not live.`); + } + if (parent && parent.id === agentId) { + throw new Error("Cannot register a Pinet agent as its own parent."); + } + if (parent && this.isAgentAncestor(agentId, parent.id)) { + throw new Error("Cannot register a Pinet agent under one of its descendants."); + } + + const supervisionState = parent + ? "supervised" + : normalizeAgentSupervisionState( + getOptionalMetadataString(metadata, ["supervisionState", "pinetSupervisionState"]) ?? + existingRow?.supervision_state, + ); + const rootAgentId = parent + ? (parent.rootAgentId ?? parent.id) + : (getOptionalMetadataString(metadata, ["rootAgentId", "pinetRootAgentId"]) ?? + existingRow?.root_agent_id ?? + null); + const treeDepth = parent ? (parent.treeDepth ?? 0) + 1 : (existingRow?.tree_depth ?? 0); + const spawnedByAgentId = + getOptionalMetadataString(metadata, ["spawnedByAgentId", "pinetSpawnedByAgentId"]) ?? + (parent ? parent.id : (existingRow?.spawned_by_agent_id ?? null)); + + return { + parentAgentId: parent?.id ?? null, + rootAgentId, + treeDepth, + spawnedByAgentId, + supervisionState, + launchId: + getOptionalMetadataString(metadata, ["launchId", "pinetLaunchId"]) ?? + existingRow?.launch_id ?? + null, + subtreeRole: + getOptionalMetadataString(metadata, ["subtreeRole", "pinetSubtreeRole"]) ?? + existingRow?.subtree_role ?? + null, + laneId: + getOptionalMetadataString(metadata, ["laneId", "pinetLaneId"]) ?? + existingRow?.lane_id ?? + null, + }; + } + unregisterAgent(id: string): void { const db = this.getDb(); const now = new Date().toISOString(); this.withTransaction(() => { + const agent = this.getAgentById(id); this.requeueUndeliveredMessagesInternal(id, "agent_disconnected"); db.prepare("DELETE FROM inbox WHERE agent_id = ?").run(id); db.prepare("UPDATE agents SET disconnected_at = ?, resumable_until = NULL WHERE id = ?").run( @@ -1559,6 +1760,10 @@ export class BrokerDB implements BrokerDBInterface { id, ); db.prepare("UPDATE threads SET owner_agent = NULL WHERE owner_agent = ?").run(id); + if (agent?.parentAgentId) { + this.notifyParentOfChildExit(agent, "unregistered"); + } + this.markDescendantsOrphaned(id, "parent_unregistered"); }); } @@ -1573,6 +1778,102 @@ export class BrokerDB implements BrokerDBInterface { ); } + private getDirectChildren(parentAgentId: string): AgentInfo[] { + const rows = this.getDb() + .prepare("SELECT * FROM agents WHERE parent_agent_id = ? ORDER BY connected_at ASC") + .all(parentAgentId) as unknown as AgentRow[]; + return rows.map(rowToAgent); + } + + getAgentDescendants(parentAgentId: string, includeDisconnected = false): AgentInfo[] { + const descendants: AgentInfo[] = []; + const seen = new Set(); + const queue = this.getDirectChildren(parentAgentId); + while (queue.length > 0) { + const child = queue.shift(); + if (!child || seen.has(child.id)) continue; + seen.add(child.id); + if (includeDisconnected || !child.disconnectedAt) { + descendants.push(child); + } + queue.push(...this.getDirectChildren(child.id)); + } + return descendants; + } + + isAgentAncestor(ancestorAgentId: string, descendantAgentId: string): boolean { + let current = this.getAgentById(descendantAgentId); + const seen = new Set(); + while (current?.parentAgentId) { + if (current.parentAgentId === ancestorAgentId) return true; + if (seen.has(current.parentAgentId)) return false; + seen.add(current.parentAgentId); + current = this.getAgentById(current.parentAgentId); + } + return false; + } + + private notifyParentOfChildExit(agent: AgentInfo, reason: string): void { + const parentId = agent.parentAgentId; + if (!parentId) return; + const parent = this.getAgentById(parentId); + if (!parent || parent.disconnectedAt) return; + const threadId = `a2a:${agent.id}:${parentId}`; + this.createThread(threadId, "agent", `agent:${parentId}`, parentId); + this.insertMessage( + threadId, + "agent", + "inbound", + agent.id, + `Child worker ${agent.name} (${agent.id}) exited: ${reason}.`, + [parentId], + { + a2a: true, + senderAgent: agent.name, + pinetMailClass: "fwup", + subtree: true, + childAgentId: agent.id, + parentAgentId: parentId, + lifecycle: "child_exit", + reason, + }, + ); + } + + private markDescendantsOrphaned(parentAgentId: string, reason: string): void { + const descendants = this.getAgentDescendants(parentAgentId, true); + if (descendants.length === 0) return; + const db = this.getDb(); + const update = db.prepare( + "UPDATE agents SET supervision_state = 'orphaned', parent_agent_id = NULL WHERE id = ?", + ); + for (const child of descendants) { + update.run(child.id); + if (!child.disconnectedAt) { + const threadId = `a2a:${parentAgentId}:${child.id}`; + this.createThread(threadId, "agent", `agent:${child.id}`, child.id); + this.insertMessage( + threadId, + "agent", + "inbound", + parentAgentId, + `Parent worker ${parentAgentId} is no longer supervising this subtree (${reason}); this worker is now orphaned and should stop or await broker recovery instructions.`, + [child.id], + { + a2a: true, + senderAgent: "Pinet lifecycle", + pinetMailClass: "steering", + subtree: true, + parentAgentId, + childAgentId: child.id, + lifecycle: "parent_orphaned_child", + reason, + }, + ); + } + } + } + getAgentById(id: string): AgentInfo | null { const row = this.getAgentRowById(id); return row ? rowToAgent(row) : null; @@ -1908,11 +2209,11 @@ export class BrokerDB implements BrokerDBInterface { return this.withTransaction(() => { const staleRows = db .prepare( - `SELECT id FROM agents + `SELECT * FROM agents WHERE (disconnected_at IS NULL AND last_heartbeat <= ?) OR (disconnected_at IS NOT NULL AND resumable_until IS NOT NULL AND resumable_until <= ?)`, ) - .all(cutoff, now) as Array<{ id: string }>; + .all(cutoff, now) as unknown as AgentRow[]; if (staleRows.length === 0) { return []; @@ -1926,9 +2227,14 @@ export class BrokerDB implements BrokerDBInterface { ); for (const row of staleRows) { + const agent = rowToAgent(row); this.requeueUndeliveredMessagesInternal(row.id, "agent_disconnected"); disconnectAgent.run(now, row.id); releaseClaims.run(row.id); + if (agent.parentAgentId) { + this.notifyParentOfChildExit(agent, "stale_heartbeat"); + } + this.markDescendantsOrphaned(row.id, "parent_stale"); } return staleRows.map((row) => row.id); @@ -1944,12 +2250,12 @@ export class BrokerDB implements BrokerDBInterface { return this.withTransaction(() => { const rows = db .prepare( - `SELECT id FROM agents + `SELECT * FROM agents WHERE disconnected_at IS NOT NULL AND disconnected_at <= ? AND (resumable_until IS NULL OR resumable_until <= ?)`, ) - .all(cutoff, nowIso) as Array<{ id: string }>; + .all(cutoff, nowIso) as unknown as AgentRow[]; if (rows.length === 0) { return []; @@ -1961,12 +2267,17 @@ export class BrokerDB implements BrokerDBInterface { const deleteInbox = db.prepare("DELETE FROM inbox WHERE agent_id = ?"); for (const row of rows) { + const agent = rowToAgent(row); // Requeue undelivered messages to the backlog this.requeueUndeliveredMessagesInternal(row.id, "agent_disconnected"); // Release thread ownership for the purged agent releaseThreads.run(row.id); // Clean up all inbox entries (both delivered and undelivered) for the agent deleteInbox.run(row.id); + if (agent.parentAgentId) { + this.notifyParentOfChildExit(agent, "purged"); + } + this.markDescendantsOrphaned(row.id, "parent_purged"); } db.prepare( diff --git a/broker-core/types.ts b/broker-core/types.ts index 411e848..f55e3dc 100644 --- a/broker-core/types.ts +++ b/broker-core/types.ts @@ -2,6 +2,8 @@ import type { PinetMailClass } from "./mail-classification.js"; // ─── Domain types ───────────────────────────────────────── +export type AgentSupervisionState = "root" | "supervised" | "orphaned" | "stopping"; + export interface AgentInfo { id: string; stableId?: string | null; @@ -13,6 +15,14 @@ export interface AgentInfo { lastHeartbeat: string; metadata: Record | null; status: "working" | "idle"; + parentAgentId?: string | null; + rootAgentId?: string | null; + treeDepth?: number; + spawnedByAgentId?: string | null; + supervisionState?: AgentSupervisionState; + launchId?: string | null; + subtreeRole?: string | null; + laneId?: string | null; disconnectedAt?: string | null; resumableUntil?: string | null; idleSince?: string | null; diff --git a/plans/761-pinet-worker-subtrees.md b/plans/761-pinet-worker-subtrees.md new file mode 100644 index 0000000..eb5c8dd --- /dev/null +++ b/plans/761-pinet-worker-subtrees.md @@ -0,0 +1,21 @@ +# #761 Pinet worker subtrees implementation note + +This branch implements the safe staging layer for worker-owned Pinet subtrees. + +## Implemented in this branch + +- Adds typed agent hierarchy fields in the broker database: parent/root ids, tree depth, spawned-by audit, supervision state, launch id, subtree role, and lane id. +- Accepts hierarchy metadata during real Pinet follower registration, including env-derived metadata from broker-managed launches. +- Preserves the broker protocol boundary: supervised children are still normal broker-connected followers, but ordinary broadcasts/backlog assignment exclude supervised children. +- Adds subtree-aware A2A policy: parent/child/ancestor/descendant messages are allowed; unrelated workers cannot directly message supervised children; broker emergency overrides remain explicit. +- Adds lifecycle handling: child exit notifies its parent; parent unregister/reap/purge marks descendants orphaned and sends child orphan notices. +- Extends `pinet action=agents` with explicit `scope=children|subtree` plus hierarchy details in full output. +- Adds `pinet action=spawn` as a validation surface that returns a precise blocker until the #406 real follower launcher/bootstrap contract exists. + +## Deliberate blocker + +The branch does **not** start local Agent subagents. `pinet action=spawn` validates the requested child task/scope, then reports `missing_broker_connected_worker_launcher` because #406-style broker-connected worker bootstrap is not yet present as callable infrastructure. The returned details document the env contract a real launcher must provide (`PINET_PARENT_AGENT_ID`, `PINET_LAUNCH_ID`, subtree role/lane metadata, etc.). + +## Next step after #406 + +Wire the validated `spawn` action to the real launcher, create a short-lived launch token, start a tmux/process-managed Pi follower with the returned hierarchy env, wait for registration, and then deliver the child task over private A2A. diff --git a/slack-bridge/README.md b/slack-bridge/README.md index 1a2d97e..edabd36 100644 --- a/slack-bridge/README.md +++ b/slack-bridge/README.md @@ -462,7 +462,9 @@ Only broker prompt content is replaceable. Broker runtime/tool restrictions rema | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pinet` | Pinet dispatcher with token-efficient `action`-based routing (`help`, `send`, `read`, `free`, `snooze`, `schedule`, `agents`, `lanes`, `ports`, `reload`, `exit`) | -Use the dispatcher for Pinet tool actions: `pinet action=send`, `pinet action=read`, `pinet action=free`, `pinet action=snooze`, `pinet action=schedule`, `pinet action=agents`, `pinet action=lanes`, `pinet action=ports`, `pinet action=reload`, and `pinet action=exit`. Use slash commands for UI lifecycle transitions: `/pinet start`, `/pinet follow`, and `/pinet unfollow`. Dedicated direct Pinet tools (`pinet_message`, `pinet_read`, `pinet_agents`, `pinet_free`, `pinet_schedule`) are no longer registered. Legacy `pinet_*` guardrail patterns still match dispatcher action names, and legacy send policies such as `pinet_send` or `pinet_message` also cover `pinet action=send`, so existing security configs fail closed during migration. +Use the dispatcher for Pinet tool actions: `pinet action=send`, `pinet action=read`, `pinet action=free`, `pinet action=snooze`, `pinet action=schedule`, `pinet action=agents`, `pinet action=lanes`, `pinet action=ports`, `pinet action=spawn`, `pinet action=reload`, and `pinet action=exit`. Use slash commands for UI lifecycle transitions: `/pinet start`, `/pinet follow`, and `/pinet unfollow`. Dedicated direct Pinet tools (`pinet_message`, `pinet_read`, `pinet_agents`, `pinet_free`, `pinet_schedule`) are no longer registered. Legacy `pinet_*` guardrail patterns still match dispatcher action names, and legacy send policies such as `pinet_send` or `pinet_message` also cover `pinet action=send`, so existing security configs fail closed during migration. + +Worker subtree support is staged behind real broker-connected followers. Broker registration now stores typed hierarchy metadata (`parentAgentId`, `rootAgentId`, `treeDepth`, `supervisionState`, `launchId`, `subtreeRole`, `laneId`) so a parent worker can supervise child workers over private A2A while the main broker remains globally observable. `pinet action=agents args.scope=children|subtree args.parent_agent= full=true` inspects a subtree. `pinet action=spawn` currently validates a child task request and returns the `missing_broker_connected_worker_launcher` blocker until the #406 launcher/bootstrap path can start a real follower with `PINET_PARENT_AGENT_ID`, `PINET_LAUNCH_ID`, and related hierarchy env. Dispatcher content defaults to terse CLI-style confirmations/summaries for noisy reads, sends, and agent lists. In default CLI mode, bulky read/agent payloads are also compacted in `data.details` so tool renderers do not surface full message bodies or agent metadata by accident. Pass `args.format="json"` (or `args.f` / `args["-f"]`) for the dispatcher envelope in content with full structured `data.details`, or `args.full=true` / `args["--full"]=true` for verbose text with full structured `data.details`. diff --git a/slack-bridge/broker/helpers.test.ts b/slack-bridge/broker/helpers.test.ts index 4b40138..7efcee2 100644 --- a/slack-bridge/broker/helpers.test.ts +++ b/slack-bridge/broker/helpers.test.ts @@ -609,6 +609,64 @@ describe("BrokerDB", () => { expect(agents[0].id).toBe("a1"); }); + it("registerAgent stores typed Pinet subtree hierarchy metadata", () => { + const parent = db.registerAgent("parent", "Parent", "🧭", 100); + const child = db.registerAgent("child", "Child", "🪴", 101, { + parentAgentId: parent.id, + spawnedByAgentId: parent.id, + launchId: "launch-1", + subtreeRole: "reviewer", + laneId: "issue-761", + }); + + expect(child).toMatchObject({ + parentAgentId: parent.id, + rootAgentId: parent.id, + treeDepth: 1, + spawnedByAgentId: parent.id, + supervisionState: "supervised", + launchId: "launch-1", + subtreeRole: "reviewer", + laneId: "issue-761", + }); + expect(db.getAgentDescendants(parent.id).map((agent) => agent.id)).toEqual(["child"]); + }); + + it("unregisterAgent orphans descendants and notifies supervised parents when children exit", () => { + db.registerAgent("parent", "Parent", "🧭", 100); + db.registerAgent("child", "Child", "🪴", 101, { parentAgentId: "parent" }); + db.registerAgent("grandchild", "Grandchild", "🌱", 102, { parentAgentId: "child" }); + + db.unregisterAgent("child"); + + expect(db.getAgentById("grandchild")).toMatchObject({ + parentAgentId: null, + supervisionState: "orphaned", + }); + const parentInbox = db.readInbox("parent", { unreadOnly: false, markRead: false }); + expect(parentInbox.messages[0]?.message.body).toContain("Child worker Child (child) exited"); + const grandchildInbox = db.readInbox("grandchild", { unreadOnly: false, markRead: false }); + expect(grandchildInbox.messages[0]?.message.metadata).toMatchObject({ + lifecycle: "parent_orphaned_child", + parentAgentId: "child", + childAgentId: "grandchild", + }); + }); + + it("rejects dead parents and hierarchy cycles during subtree registration", () => { + db.registerAgent("parent", "Parent", "🧭", 100); + db.registerAgent("child", "Child", "🪴", 101, { parentAgentId: "parent" }); + + expect(() => + db.registerAgent("parent", "Parent", "🧭", 100, { parentAgentId: "child" }), + ).toThrow("descendants"); + + db.unregisterAgent("parent"); + expect(() => + db.registerAgent("new-child", "New Child", "🌱", 102, { parentAgentId: "parent" }), + ).toThrow("parent parent is not live"); + }); + it("registerAgent upserts on conflict", () => { db.registerAgent("a1", "First", "🔵", 100); db.registerAgent("a1", "Updated", "🔴", 200); diff --git a/slack-bridge/broker/integration.test.ts b/slack-bridge/broker/integration.test.ts index 04b26ba..83cc1bb 100644 --- a/slack-bridge/broker/integration.test.ts +++ b/slack-bridge/broker/integration.test.ts @@ -686,6 +686,38 @@ describe("broker integration — client ↔ server ↔ DB", () => { client2.disconnect(); }); + it("agent.message allows parent-child supervision but rejects unrelated supervised-child routing", async () => { + const parentReg = await client.register("parent-agent", "🧭"); + + const info = server.getConnectInfo(); + if (info.type !== "tcp") throw new Error("Expected TCP"); + const childClient = new BrokerClient({ host: info.host, port: info.port }); + await childClient.connect(); + const childReg = await childClient.register("child-agent", "🌱", { + parentAgentId: parentReg.agentId, + }); + + const unrelatedClient = new BrokerClient({ host: info.host, port: info.port }); + await unrelatedClient.connect(); + await unrelatedClient.register("unrelated-agent", "🪨"); + + await expect(client.sendAgentMessage(childReg.agentId, "parent ping")).resolves.toBeGreaterThan( + 0, + ); + await expect( + childClient.sendAgentMessage(parentReg.agentId, "child pong"), + ).resolves.toBeGreaterThan(0); + await expect( + unrelatedClient.sendAgentMessage(childReg.agentId, "side ping", { + emergency: true, + targetScope: "subtree", + }), + ).rejects.toThrow("cannot message supervised agent"); + + childClient.disconnect(); + unrelatedClient.disconnect(); + }); + it("agent.message resolves target by ID", async () => { await client.register("alpha", "🅰️"); diff --git a/slack-bridge/helpers.ts b/slack-bridge/helpers.ts index 65e2945..864d786 100644 --- a/slack-bridge/helpers.ts +++ b/slack-bridge/helpers.ts @@ -936,6 +936,12 @@ export interface AgentDisplayInfo { launchSource?: string; tmuxSession?: string; brokerManagedAt?: string; + parentAgentId?: string; + rootAgentId?: string; + treeDepth?: number; + supervisionState?: string; + subtreeRole?: string; + laneId?: string; skinTheme?: string; personality?: string; skinStatusVocabulary?: PinetSkinStatusVocabulary; @@ -976,6 +982,12 @@ export interface AgentVisibilityInput { lastActivity?: string | null; outboundCount?: number | null; pendingInboxCount?: number | null; + parentAgentId?: string | null; + rootAgentId?: string | null; + treeDepth?: number; + supervisionState?: string; + subtreeRole?: string | null; + laneId?: string | null; } export interface AgentVisibilityOptions { @@ -1241,8 +1253,17 @@ export function buildAgentDisplayInfo( } const metadata = asRecord(agent.metadata); + const hasHierarchyMetadata = Boolean( + agent.parentAgentId || + agent.rootAgentId || + typeof agent.treeDepth === "number" || + agent.supervisionState || + agent.subtreeRole || + agent.laneId, + ); const capabilities = extractAgentCapabilities(metadata); const capabilityTags = buildAgentCapabilityTags(capabilities); + const displayMetadata = metadata ?? {}; const idleSinceMs = parseIsoMs(agent.idleSince); const lastActivityMs = parseIsoMs(agent.lastActivity); @@ -1255,39 +1276,46 @@ export function buildAgentDisplayInfo( id: agent.id, ...(agent.pid != null ? { pid: agent.pid } : {}), status: agent.status, - metadata: metadata - ? { - cwd: asString(metadata.cwd), - branch: asString(metadata.branch), - ...(metadata.workdirDirty === true ? { workdirDirty: true } : {}), - ...(typeof metadata.workdirDirtyFileCount === "number" - ? { workdirDirtyFileCount: metadata.workdirDirtyFileCount } - : {}), - ...(metadata.gitProbeFailed === true ? { gitProbeFailed: true } : {}), - ...(asString(metadata.gitProbedAt) - ? { gitProbedAt: asString(metadata.gitProbedAt) } - : {}), - host: asString(metadata.host), - repo: asString(metadata.repo) ?? capabilities.repo, - role: asString(metadata.role) ?? capabilities.role, - brokerManaged: metadata.brokerManaged === true, - brokerManagedBy: asString(metadata.brokerManagedBy), - launchSource: asString(metadata.launchSource), - tmuxSession: asString(metadata.tmuxSession), - brokerManagedAt: asString(metadata.brokerManagedAt), - skinTheme: asString(metadata.skinTheme), - personality: asString(metadata.personality), - ...(extractPinetSkinStatusVocabulary(metadata.skinStatusVocabulary) - ? { - skinStatusVocabulary: extractPinetSkinStatusVocabulary( - metadata.skinStatusVocabulary, - ), - } - : {}), - ...(capabilities.scope ? { scope: capabilities.scope } : {}), - capabilities, - } - : null, + metadata: + metadata || hasHierarchyMetadata + ? { + cwd: asString(displayMetadata.cwd), + branch: asString(displayMetadata.branch), + ...(displayMetadata.workdirDirty === true ? { workdirDirty: true } : {}), + ...(typeof displayMetadata.workdirDirtyFileCount === "number" + ? { workdirDirtyFileCount: displayMetadata.workdirDirtyFileCount } + : {}), + ...(displayMetadata.gitProbeFailed === true ? { gitProbeFailed: true } : {}), + ...(asString(displayMetadata.gitProbedAt) + ? { gitProbedAt: asString(displayMetadata.gitProbedAt) } + : {}), + host: asString(displayMetadata.host), + repo: asString(displayMetadata.repo) ?? capabilities.repo, + role: asString(displayMetadata.role) ?? capabilities.role, + brokerManaged: displayMetadata.brokerManaged === true, + brokerManagedBy: asString(displayMetadata.brokerManagedBy), + launchSource: asString(displayMetadata.launchSource), + tmuxSession: asString(displayMetadata.tmuxSession), + brokerManagedAt: asString(displayMetadata.brokerManagedAt), + parentAgentId: agent.parentAgentId ?? asString(displayMetadata.parentAgentId), + rootAgentId: agent.rootAgentId ?? asString(displayMetadata.rootAgentId), + ...(typeof agent.treeDepth === "number" ? { treeDepth: agent.treeDepth } : {}), + supervisionState: agent.supervisionState, + subtreeRole: agent.subtreeRole ?? asString(displayMetadata.subtreeRole), + laneId: agent.laneId ?? asString(displayMetadata.laneId), + skinTheme: asString(displayMetadata.skinTheme), + personality: asString(displayMetadata.personality), + ...(extractPinetSkinStatusVocabulary(displayMetadata.skinStatusVocabulary) + ? { + skinStatusVocabulary: extractPinetSkinStatusVocabulary( + displayMetadata.skinStatusVocabulary, + ), + } + : {}), + ...(capabilities.scope ? { scope: capabilities.scope } : {}), + capabilities, + } + : null, lastHeartbeat: agent.lastHeartbeat, leaseExpiresAt: computedLeaseExpiresAt, heartbeatAgeMs, @@ -2551,6 +2579,18 @@ export function formatAgentList(agents: AgentDisplayInfo[], homedir: string): st line += `\n ${cwd}${branch}${host}${probe}`; } + if (meta?.parentAgentId || meta?.supervisionState === "orphaned") { + const hierarchy = [ + meta.parentAgentId ? `parent=${meta.parentAgentId}` : null, + meta.rootAgentId ? `root=${meta.rootAgentId}` : null, + typeof meta.treeDepth === "number" ? `depth=${meta.treeDepth}` : null, + meta.supervisionState ? `state=${meta.supervisionState}` : null, + meta.subtreeRole ? `role=${meta.subtreeRole}` : null, + meta.laneId ? `lane=${meta.laneId}` : null, + ].filter((item): item is string => Boolean(item)); + line += `\n subtree: ${hierarchy.join(" · ")}`; + } + if (meta?.brokerManaged) { const managed = [ meta.launchSource ? `source=${meta.launchSource}` : "source=broker", diff --git a/slack-bridge/pinet-mesh-ops.ts b/slack-bridge/pinet-mesh-ops.ts index bfcdd17..9365278 100644 --- a/slack-bridge/pinet-mesh-ops.ts +++ b/slack-bridge/pinet-mesh-ops.ts @@ -26,6 +26,12 @@ export interface PinetMeshOpsAgentRecord { resumableUntil?: string | null; outboundCount?: number; pendingInboxCount?: number; + parentAgentId?: string | null; + rootAgentId?: string | null; + treeDepth?: number; + supervisionState?: string; + subtreeRole?: string | null; + laneId?: string | null; } export interface PinetMeshOpsRecordedAssignment { @@ -210,6 +216,7 @@ export function createPinetMeshOps(deps: PinetMeshOpsDeps): PinetMeshOps { target: targetRef, body: finalBody, metadata: finalMetadata, + trustedBrokerAgentId: selfId, }); if (transferThreadId) { @@ -371,6 +378,12 @@ export function createPinetMeshOps(deps: PinetMeshOpsDeps): PinetMeshOps { resumableUntil: agent.resumableUntil, outboundCount: agent.outboundCount, pendingInboxCount: db.getPendingInboxCount(agent.id), + parentAgentId: agent.parentAgentId, + rootAgentId: agent.rootAgentId, + treeDepth: agent.treeDepth, + supervisionState: agent.supervisionState, + subtreeRole: agent.subtreeRole, + laneId: agent.laneId, })); } @@ -393,6 +406,12 @@ export function createPinetMeshOps(deps: PinetMeshOpsDeps): PinetMeshOps { resumableUntil: agent.resumableUntil, outboundCount: agent.outboundCount, pendingInboxCount: agent.pendingInboxCount, + parentAgentId: agent.parentAgentId, + rootAgentId: agent.rootAgentId, + treeDepth: agent.treeDepth, + supervisionState: agent.supervisionState, + subtreeRole: agent.subtreeRole, + laneId: agent.laneId, })); } diff --git a/slack-bridge/pinet-tools.test.ts b/slack-bridge/pinet-tools.test.ts index 358f0c4..a3da31d 100644 --- a/slack-bridge/pinet-tools.test.ts +++ b/slack-bridge/pinet-tools.test.ts @@ -1170,6 +1170,47 @@ describe("registerPinetTools", () => { expect(listFollowerAgents).toHaveBeenNthCalledWith(2, true); }); + it("filters pinet agents by explicit subtree scope and shows hierarchy metadata", async () => { + const listBrokerAgents = vi.fn(() => [ + makeAgent({ id: "parent", name: "Parent" }), + makeAgent({ + id: "child", + name: "Child", + parentAgentId: "parent", + rootAgentId: "parent", + treeDepth: 1, + supervisionState: "supervised", + subtreeRole: "reviewer", + laneId: "issue-761", + }), + ]); + const tools = registerWithDeps(createDeps({ listBrokerAgents })); + + const result = (await tools.get("pinet")?.execute("tool-call-agents-children", { + action: "agents", + args: { scope: "children", parent_agent: "parent", full: true }, + })) as { details: { data: { text: string; details: { agents: Array<{ id: string }> } } } }; + + expect(result.details.data.details.agents.map((agent) => agent.id)).toEqual(["child"]); + expect(result.details.data.text).toContain("subtree: parent=parent"); + expect(result.details.data.text).toContain("role=reviewer"); + }); + + it("validates spawn requests but reports the missing real-follower launcher blocker", async () => { + const tools = registerWithDeps(createDeps({ brokerRole: () => "follower" })); + + const result = (await tools.get("pinet")?.execute("tool-call-spawn", { + action: "spawn", + args: { task: "Review PR #761", repo: "extensions", role: "reviewer", full: true }, + })) as { details: { data: { text: string; details: { status: string; blocker: string } } } }; + + expect(result.details.data.text).toContain("launcher is unavailable"); + expect(result.details.data.details).toMatchObject({ + status: "blocked", + blocker: "missing_broker_connected_worker_launcher", + }); + }); + it("keeps pinet agents default cli details compact", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-14T12:00:00Z")); diff --git a/slack-bridge/pinet-tools.ts b/slack-bridge/pinet-tools.ts index b3099be..5f8bde7 100644 --- a/slack-bridge/pinet-tools.ts +++ b/slack-bridge/pinet-tools.ts @@ -55,6 +55,12 @@ export interface PinetToolsAgentRecord { resumableUntil?: string | null; outboundCount?: number; pendingInboxCount?: number; + parentAgentId?: string | null; + rootAgentId?: string | null; + treeDepth?: number; + supervisionState?: string; + subtreeRole?: string | null; + laneId?: string | null; } export interface RegisterPinetToolsDeps { @@ -111,6 +117,7 @@ interface PinetAgentsRoutingHint { role?: string; requiredTools?: string[]; task?: string; + scope?: "visible" | "children" | "subtree" | "all"; } type PinetDispatcherAction = @@ -122,6 +129,7 @@ type PinetDispatcherAction = | "agents" | "lanes" | "ports" + | "spawn" | "reload" | "exit" | "help"; @@ -174,6 +182,12 @@ const PINET_DISPATCHER_EXAMPLES: Record>> ], schedule: [{ action: "schedule", args: { delay: "30m", message: "Check queue state" } }], agents: [{ action: "agents", args: { repo: "", role: "worker", full: true } }], + spawn: [ + { + action: "spawn", + args: { task: "Review PR #123", repo: "extensions", role: "reviewer", lane_id: "issue-123" }, + }, + ], ports: [ { action: "ports", args: { op: "acquire", purpose: "preview", ttl_ms: 600000 } }, { action: "ports", args: { op: "renew", lease_id: "", ttl_ms: 600000 } }, @@ -244,6 +258,7 @@ function normalizeDispatcherAction(value: unknown): PinetDispatcherAction { "agents", "lanes", "ports", + "spawn", "reload", "exit", ]; @@ -293,7 +308,8 @@ function classifyPinetError(message: string): PinetDispatcherError { message.includes("lease_id") || message.includes("ttl_ms") || message.includes("purpose") || - message.includes("port") + message.includes("port") || + message.includes("spawn") ) { return { class: "input", @@ -724,6 +740,59 @@ function runPinetSnoozeAction( })(); } +function runPinetSpawnAction( + params: Record, + deps: RegisterPinetToolsDeps, + toolName: string, +): Promise { + return (async () => { + const task = getMaybeString(params, "task"); + const repo = getMaybeString(params, "repo"); + const role = getMaybeString(params, "role") ?? "subworker"; + const laneId = getMaybeString(params, "lane_id"); + deps.requireToolPolicy( + toolName, + undefined, + `task=${task ?? ""} | repo=${repo ?? ""} | role=${role} | lane_id=${laneId ?? ""}`, + ); + if (!deps.pinetEnabled()) { + throw new Error("Pinet is not running. Use /pinet start or /pinet follow first."); + } + if (deps.brokerRole() !== "follower") { + throw new Error( + "spawn is worker-only; the broker should launch top-level followers, not own subtrees.", + ); + } + if (!task) throw new Error("spawn requires task"); + if (!repo) throw new Error("spawn requires repo"); + + const launchId = `spawn-${Date.now().toString(36)}`; + return { + content: [ + { + type: "text", + text: "Pinet spawn validated but launcher is unavailable: #406 broker-connected worker bootstrap is required before a real follower can be started safely.", + }, + ], + details: { + launchId, + status: "blocked", + blocker: "missing_broker_connected_worker_launcher", + requiredBootstrapEnv: { + PINET_BROKER_MANAGED: "1", + PINET_PARENT_AGENT_ID: "", + PINET_SPAWNED_BY_AGENT_ID: "", + PINET_LAUNCH_ID: launchId, + PINET_SUBTREE_ROLE: role, + ...(laneId ? { PINET_LANE_ID: laneId } : {}), + }, + }, + compactDetails: { status: "blocked", blocker: "missing_broker_connected_worker_launcher" }, + fullDetails: { task, repo, role, laneId: laneId ?? null, launchId }, + }; + })(); +} + function runPinetRemoteControlAction( params: Record, deps: RegisterPinetToolsDeps, @@ -844,6 +913,12 @@ function buildCompactAgentDetails( brokerManaged: agent.metadata?.brokerManaged === true, launchSource: agent.metadata?.launchSource ?? null, tmuxSession: agent.metadata?.tmuxSession ?? null, + parentAgentId: agent.metadata?.parentAgentId ?? null, + rootAgentId: agent.metadata?.rootAgentId ?? null, + treeDepth: agent.metadata?.treeDepth ?? 0, + supervisionState: agent.metadata?.supervisionState ?? "root", + subtreeRole: agent.metadata?.subtreeRole ?? null, + laneId: agent.metadata?.laneId ?? null, routingScore: agent.routingScore ?? null, ...(agent.pendingInboxCount != null && agent.pendingInboxCount > 0 ? { pendingInboxCount: agent.pendingInboxCount } @@ -855,6 +930,30 @@ function buildCompactAgentDetails( }; } +function filterAgentsForHierarchyScope( + agents: PinetToolsAgentRecord[], + scope: "visible" | "children" | "subtree" | "all", + parentAgentId: string | undefined, +): PinetToolsAgentRecord[] { + if (scope === "all" || scope === "visible") return agents; + if (!parentAgentId) return []; + if (scope === "children") { + return agents.filter((agent) => agent.parentAgentId === parentAgentId); + } + + const descendants: PinetToolsAgentRecord[] = []; + const seen = new Set(); + const queue = agents.filter((agent) => agent.parentAgentId === parentAgentId); + while (queue.length > 0) { + const child = queue.shift(); + if (!child || seen.has(child.id)) continue; + seen.add(child.id); + descendants.push(child); + queue.push(...agents.filter((agent) => agent.parentAgentId === child.id)); + } + return descendants; +} + function formatCompactAgentList(agents: AgentDisplayInfo[], hint: PinetAgentsRoutingHint): string { const hintParts = [ hint.repo ? `repo=${hint.repo}` : null, @@ -865,7 +964,8 @@ function formatCompactAgentList(agents: AgentDisplayInfo[], hint: PinetAgentsRou : null, ].filter((item): item is string => Boolean(item)); const hintSuffix = hintParts.length > 0 ? `; hints ${hintParts.join(" · ")}` : ""; - return `Pinet agents: ${agents.length} visible${hintSuffix}.`; + const scopeSuffix = hint.scope && hint.scope !== "visible" ? `; scope=${hint.scope}` : ""; + return `Pinet agents: ${agents.length} visible${hintSuffix}${scopeSuffix}.`; } function formatPinetLaneSummary(lane: PinetLaneInfo): string { @@ -1226,10 +1326,16 @@ function runPinetAgentsAction( output: PinetOutputOptions, ): Promise { return (async () => { + const scope = + params.scope === "children" || params.scope === "subtree" || params.scope === "all" + ? params.scope + : "visible"; + const parentAgentId = typeof params.parent_agent === "string" ? params.parent_agent : undefined; const hint: PinetAgentsRoutingHint = { repo: typeof params.repo === "string" ? params.repo : undefined, branch: typeof params.branch === "string" ? params.branch : undefined, role: typeof params.role === "string" ? params.role : undefined, + ...(scope !== "visible" ? { scope } : {}), requiredTools: typeof params.required_tools === "string" ? params.required_tools @@ -1243,7 +1349,7 @@ function runPinetAgentsAction( deps.requireToolPolicy( toolName, undefined, - `repo=${hint.repo ?? ""} | branch=${hint.branch ?? ""} | role=${hint.role ?? ""} | required_tools=${params.required_tools ?? ""} | task=${hint.task ?? ""} | format=${output.format} | full=${output.full}`, + `repo=${hint.repo ?? ""} | branch=${hint.branch ?? ""} | role=${hint.role ?? ""} | scope=${scope} | parent_agent=${parentAgentId ?? ""} | required_tools=${params.required_tools ?? ""} | task=${hint.task ?? ""} | format=${output.format} | full=${output.full}`, ); if (!deps.pinetEnabled()) { @@ -1272,7 +1378,8 @@ function runPinetAgentsAction( throw new Error("Pinet is in an unexpected state."); } - const visibleAgents = filterAgentsForMeshVisibility(rawAgents, { + const scopedRawAgents = filterAgentsForHierarchyScope(rawAgents, scope, parentAgentId); + const visibleAgents = filterAgentsForMeshVisibility(scopedRawAgents, { now: nowMs, includeGhosts, recentDisconnectWindowMs: recentGhostWindowMs, @@ -1371,6 +1478,20 @@ export function registerPinetTools(pi: ExtensionAPI, deps: RegisterPinetToolsDep execute: (_id, params, _output) => runPinetSnoozeAction(params, deps, "pinet:snooze"), }); + registerAction({ + name: "spawn", + description: + "Worker-only: validate a request for a real broker-connected Pinet sub-worker. Returns a precise launcher blocker until #406 bootstrap is available.", + parameters: Type.Object({ + task: Type.String({ description: "Scoped task prompt for the child worker" }), + repo: Type.String({ description: "Repo/workspace scope for the child worker" }), + role: Type.Optional(Type.String({ description: "Child subtree role, e.g. reviewer" })), + lane_id: Type.Optional(Type.String({ description: "Optional durable Pinet lane id" })), + ...PINET_OUTPUT_OPTION_PARAMETERS, + }), + execute: (_id, params, _output) => runPinetSpawnAction(params, deps, "pinet:spawn"), + }); + registerAction({ name: "reload", description: "Ask another connected Pinet agent to reload itself.", @@ -1429,6 +1550,15 @@ export function registerPinetTools(pi: ExtensionAPI, deps: RegisterPinetToolsDep "Include recently disconnected/resumable agents. Defaults false so graceful exits do not look like actionable ghosts.", }), ), + scope: Type.Optional( + Type.String({ + description: + "Hierarchy filter: visible (default), children, subtree, or all. children/subtree require parent_agent for explicit inspection.", + }), + ), + parent_agent: Type.Optional( + Type.String({ description: "Parent agent id for scope=children or scope=subtree" }), + ), ...PINET_OUTPUT_OPTION_PARAMETERS, }), execute: (_id, params, output) => runPinetAgentsAction(params, deps, "pinet:agents", output), @@ -1552,11 +1682,11 @@ export function registerPinetTools(pi: ExtensionAPI, deps: RegisterPinetToolsDep label: "Pinet Dispatcher", description: "Dispatch Pinet operations by action with compact help and schema discovery.", promptSnippet: - 'Use this compact dispatcher for Pinet actions: send, read, free, snooze, schedule, agents, lanes, ports, reload, exit, and help. Use /pinet start, /pinet follow, and /pinet unfollow for TUI lifecycle changes. Defaults to terse CLI text; pass args.format="json" or args.full=true for explicit detail, but avoid JSON/full unless needed because it can fill context quickly.', + 'Use this compact dispatcher for Pinet actions: send, read, free, snooze, schedule, agents, lanes, ports, reload, exit, spawn, and help. Use /pinet start, /pinet follow, and /pinet unfollow for TUI lifecycle changes. Defaults to terse CLI text; pass args.format="json" or args.full=true for explicit detail, but avoid JSON/full unless needed because it can fill context quickly.', parameters: Type.Object({ action: Type.String({ description: - "Action name: help, send, read, free, snooze, schedule, agents, lanes, ports, reload, or exit.", + "Action name: help, send, read, free, snooze, schedule, agents, lanes, ports, reload, or exit. Also supports spawn for worker subtree bootstrap validation.", }), args: Type.Optional( Type.Record(Type.String(), Type.Unknown(), { diff --git a/slack-bridge/runtime-agent-context.ts b/slack-bridge/runtime-agent-context.ts index 358c847..a363bcb 100644 --- a/slack-bridge/runtime-agent-context.ts +++ b/slack-bridge/runtime-agent-context.ts @@ -403,6 +403,7 @@ export function createRuntimeAgentContext(deps: RuntimeAgentContextDeps): Runtim const skinAssignment = resolveSkinAssignment(role, getIdentitySeedForRole(role)); const brokerManaged = role === "worker" && process.env.PINET_BROKER_MANAGED === "1"; + const parentAgentId = process.env.PINET_PARENT_AGENT_ID?.trim() || undefined; const brokerManagedMetadata = brokerManaged ? { brokerManaged: true, @@ -410,6 +411,24 @@ export function createRuntimeAgentContext(deps: RuntimeAgentContextDeps): Runtim launchSource: process.env.PINET_LAUNCH_SOURCE?.trim() || "broker-tmux", tmuxSession: process.env.PINET_TMUX_SESSION?.trim() || undefined, brokerManagedAt: new Date().toISOString(), + ...(parentAgentId ? { parentAgentId, pinetParentAgentId: parentAgentId } : {}), + ...(process.env.PINET_ROOT_AGENT_ID?.trim() + ? { rootAgentId: process.env.PINET_ROOT_AGENT_ID.trim() } + : {}), + ...(process.env.PINET_SPAWNED_BY_AGENT_ID?.trim() + ? { spawnedByAgentId: process.env.PINET_SPAWNED_BY_AGENT_ID.trim() } + : parentAgentId + ? { spawnedByAgentId: parentAgentId } + : {}), + ...(process.env.PINET_LAUNCH_ID?.trim() + ? { launchId: process.env.PINET_LAUNCH_ID.trim() } + : {}), + ...(process.env.PINET_SUBTREE_ROLE?.trim() + ? { subtreeRole: process.env.PINET_SUBTREE_ROLE.trim() } + : {}), + ...(process.env.PINET_LANE_ID?.trim() + ? { laneId: process.env.PINET_LANE_ID.trim() } + : {}), } : {};