From dc10dd2e066d11876f0b2a7d0e45688d17c801eb Mon Sep 17 00:00:00 2001 From: "Grace (Clawdbot)" Date: Wed, 28 Jan 2026 04:38:52 +0100 Subject: [PATCH] feat(exec): track spawnedBySession for hierarchical process visualization - Add sessionKey field to FinishedSession interface - Preserve sessionKey when moving process sessions to finished state - Expose spawnedBySession in process list action for both running and finished sessions - Add comprehensive tests for spawnedBySession tracking This enables hierarchical visualization of sessions containing their child processes, complementing the existing spawnedBy tracking for sub-agent sessions. --- src/agents/bash-process-registry.ts | 2 + ...h-tools.process.spawned-by-session.test.ts | 201 ++++++++++++++++++ src/agents/bash-tools.process.ts | 2 + 3 files changed, 205 insertions(+) create mode 100644 src/agents/bash-tools.process.spawned-by-session.test.ts diff --git a/src/agents/bash-process-registry.ts b/src/agents/bash-process-registry.ts index b32c265db..1d629f4aa 100644 --- a/src/agents/bash-process-registry.ts +++ b/src/agents/bash-process-registry.ts @@ -55,6 +55,7 @@ export interface FinishedSession { id: string; command: string; scopeKey?: string; + sessionKey?: string; startedAt: number; endedAt: number; cwd?: string; @@ -162,6 +163,7 @@ function moveToFinished(session: ProcessSession, status: ProcessStatus) { id: session.id, command: session.command, scopeKey: session.scopeKey, + sessionKey: session.sessionKey, startedAt: session.startedAt, endedAt: Date.now(), cwd: session.cwd, diff --git a/src/agents/bash-tools.process.spawned-by-session.test.ts b/src/agents/bash-tools.process.spawned-by-session.test.ts new file mode 100644 index 000000000..d9b4be59f --- /dev/null +++ b/src/agents/bash-tools.process.spawned-by-session.test.ts @@ -0,0 +1,201 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { ProcessSession } from "./bash-process-registry.js"; +import { + addSession, + listFinishedSessions, + listRunningSessions, + markBackgrounded, + markExited, + resetProcessRegistryForTests, +} from "./bash-process-registry.js"; +import { createProcessTool } from "./bash-tools.process.js"; + +describe("process spawnedBySession tracking", () => { + beforeEach(() => { + resetProcessRegistryForTests(); + }); + + it("stores sessionKey in ProcessSession when provided", () => { + const session: ProcessSession = { + id: "sess-1", + command: "echo test", + sessionKey: "agent:main:whatsapp:user:12345", + child: { pid: 123 } as ChildProcessWithoutNullStreams, + startedAt: Date.now(), + cwd: "/tmp", + maxOutputChars: 10000, + pendingMaxOutputChars: 30_000, + totalOutputChars: 0, + pendingStdout: [], + pendingStderr: [], + pendingStdoutChars: 0, + pendingStderrChars: 0, + aggregated: "", + tail: "", + exited: false, + exitCode: undefined, + exitSignal: undefined, + truncated: false, + backgrounded: true, + }; + + addSession(session); + const running = listRunningSessions(); + + expect(running).toHaveLength(1); + expect(running[0].sessionKey).toBe("agent:main:whatsapp:user:12345"); + }); + + it("preserves sessionKey in finished sessions", () => { + const session: ProcessSession = { + id: "sess-2", + command: "echo finished", + sessionKey: "agent:main:subagent:abc123", + child: { pid: 456 } as ChildProcessWithoutNullStreams, + startedAt: Date.now(), + cwd: "/tmp", + maxOutputChars: 10000, + pendingMaxOutputChars: 30_000, + totalOutputChars: 0, + pendingStdout: [], + pendingStderr: [], + pendingStdoutChars: 0, + pendingStderrChars: 0, + aggregated: "", + tail: "", + exited: false, + exitCode: undefined, + exitSignal: undefined, + truncated: false, + backgrounded: true, + }; + + addSession(session); + markExited(session, 0, null, "completed"); + + const finished = listFinishedSessions(); + expect(finished).toHaveLength(1); + expect(finished[0].sessionKey).toBe("agent:main:subagent:abc123"); + }); + + it("process list action returns spawnedBySession for running sessions", async () => { + const session: ProcessSession = { + id: "sess-3", + command: "sleep 60", + sessionKey: "agent:main:main", + child: { pid: 789 } as ChildProcessWithoutNullStreams, + pid: 789, + startedAt: Date.now(), + cwd: "/tmp", + maxOutputChars: 10000, + pendingMaxOutputChars: 30_000, + totalOutputChars: 0, + pendingStdout: [], + pendingStderr: [], + pendingStdoutChars: 0, + pendingStderrChars: 0, + aggregated: "", + tail: "", + exited: false, + exitCode: undefined, + exitSignal: undefined, + truncated: false, + backgrounded: true, + }; + + addSession(session); + + const tool = createProcessTool(); + const result = await tool.execute("call-1", { action: "list" }); + + expect(result.details).toBeDefined(); + const details = result.details as { + sessions: Array<{ sessionId: string; spawnedBySession?: string }>; + }; + expect(details.sessions).toHaveLength(1); + expect(details.sessions[0].sessionId).toBe("sess-3"); + expect(details.sessions[0].spawnedBySession).toBe("agent:main:main"); + }); + + it("process list action returns spawnedBySession for finished sessions", async () => { + const session: ProcessSession = { + id: "sess-4", + command: "echo done", + sessionKey: "agent:work:telegram:group:g1", + child: { pid: 101 } as ChildProcessWithoutNullStreams, + pid: 101, + startedAt: Date.now(), + cwd: "/tmp", + maxOutputChars: 10000, + pendingMaxOutputChars: 30_000, + totalOutputChars: 0, + pendingStdout: [], + pendingStderr: [], + pendingStdoutChars: 0, + pendingStderrChars: 0, + aggregated: "done\n", + tail: "done\n", + exited: false, + exitCode: undefined, + exitSignal: undefined, + truncated: false, + backgrounded: true, + }; + + addSession(session); + markBackgrounded(session); + markExited(session, 0, null, "completed"); + + const tool = createProcessTool(); + const result = await tool.execute("call-2", { action: "list" }); + + expect(result.details).toBeDefined(); + const details = result.details as { + sessions: Array<{ sessionId: string; spawnedBySession?: string }>; + }; + expect(details.sessions).toHaveLength(1); + expect(details.sessions[0].sessionId).toBe("sess-4"); + expect(details.sessions[0].spawnedBySession).toBe("agent:work:telegram:group:g1"); + }); + + it("process list action handles sessions without sessionKey (backward compat)", async () => { + const session: ProcessSession = { + id: "sess-5", + command: "legacy command", + // No sessionKey - simulates pre-feature sessions + child: { pid: 111 } as ChildProcessWithoutNullStreams, + pid: 111, + startedAt: Date.now(), + cwd: "/tmp", + maxOutputChars: 10000, + pendingMaxOutputChars: 30_000, + totalOutputChars: 0, + pendingStdout: [], + pendingStderr: [], + pendingStdoutChars: 0, + pendingStderrChars: 0, + aggregated: "", + tail: "", + exited: false, + exitCode: undefined, + exitSignal: undefined, + truncated: false, + backgrounded: true, + }; + + addSession(session); + + const tool = createProcessTool(); + const result = await tool.execute("call-3", { action: "list" }); + + expect(result.details).toBeDefined(); + const details = result.details as { + sessions: Array<{ sessionId: string; spawnedBySession?: string }>; + }; + expect(details.sessions).toHaveLength(1); + expect(details.sessions[0].sessionId).toBe("sess-5"); + // Should be undefined, not break + expect(details.sessions[0].spawnedBySession).toBeUndefined(); + }); +}); diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index 1463df086..1aa1887b8 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -98,6 +98,7 @@ export function createProcessTool( name: deriveSessionName(s.command), tail: s.tail, truncated: s.truncated, + spawnedBySession: s.sessionKey ?? undefined, })); const finished = listFinishedSessions() .filter((s) => isInScope(s)) @@ -114,6 +115,7 @@ export function createProcessTool( truncated: s.truncated, exitCode: s.exitCode ?? undefined, exitSignal: s.exitSignal ?? undefined, + spawnedBySession: s.sessionKey ?? undefined, })); const lines = [...running, ...finished] .sort((a, b) => b.startedAt - a.startedAt)