diff --git a/apps/server/src/agents/archive.ts b/apps/server/src/agents/archive.ts index 5613baf5..46ac0e85 100644 --- a/apps/server/src/agents/archive.ts +++ b/apps/server/src/agents/archive.ts @@ -9,6 +9,7 @@ import { import { runLifecycleHook } from "./lifecycle-hooks.js"; import { AgentError } from "./errors.js"; import type { AgentRuntime } from "./runtime.js"; +import { getReviewChildAgentIds } from "./persona-reviews.js"; import type { AgentRecord, AgentStatus, @@ -201,23 +202,20 @@ export async function executeArchive( durations.db = Date.now() - tDb; diffStatsRefresher?.clear(id); - // Cascade: archive child agents (persona agents spawned by this parent) + // Cascade: archive review child agents only (not regular launched agents) const tCascade = Date.now(); - const children = await pool.query<{ id: string }>( - "SELECT id FROM agents WHERE parent_agent_id = $1 AND deleted_at IS NULL", - [id] - ); - for (const child of children.rows) { + const reviewChildIds = await getReviewChildAgentIds(pool, id); + for (const childId of reviewChildIds) { try { - await deleteAgentDirect(deps, child.id, true, cleanupWorktree); + await deleteAgentDirect(deps, childId, true, cleanupWorktree); } catch (err) { logger.warn( - { err, childId: child.id, parentId: id }, + { err, childId, parentId: id }, "Failed to cascade-delete child agent" ); } } - if (children.rows.length > 0) { + if (reviewChildIds.length > 0) { durations.cascadeChildren = Date.now() - tCascade; } @@ -227,7 +225,7 @@ export async function executeArchive( .join(", "); logger.info({ agentId: id, durations }, `Archive durations: ${parts}`); - const deletedIds = [id, ...children.rows.map((r) => r.id)]; + const deletedIds = [id, ...reviewChildIds]; callbacks.onComplete(deletedIds); } catch (error) { logger.error({ err: error, agentId: id }, "Archive failed"); @@ -296,17 +294,14 @@ export async function deleteAgentDirect( durations.db = Date.now() - tDb; diffStatsRefresher?.clear(id); - // Cascade to any children (recursive to handle multi-level nesting) - const children = await pool.query<{ id: string }>( - "SELECT id FROM agents WHERE parent_agent_id = $1 AND deleted_at IS NULL", - [id] - ); - for (const child of children.rows) { + // Cascade to review children only (not regular launched agents) + const reviewChildIds = await getReviewChildAgentIds(pool, id); + for (const childId of reviewChildIds) { try { - await deleteAgentDirect(deps, child.id, true, cleanupWorktree); + await deleteAgentDirect(deps, childId, true, cleanupWorktree); } catch (err) { logger.warn( - { err, childId: child.id, parentId: id }, + { err, childId, parentId: id }, "Failed to cascade-delete child agent" ); } diff --git a/apps/server/src/agents/persona-reviews.ts b/apps/server/src/agents/persona-reviews.ts index 98d77a19..1db9918f 100644 --- a/apps/server/src/agents/persona-reviews.ts +++ b/apps/server/src/agents/persona-reviews.ts @@ -337,6 +337,22 @@ export async function getPersonaReviewsByParent( return result.rows; } +export async function getReviewChildAgentIds( + pool: Pool, + parentAgentId: string +): Promise { + const result = await pool.query<{ agentId: string }>( + `SELECT DISTINCT pr.agent_id AS "agentId" + FROM persona_reviews pr + JOIN agents a ON a.id = pr.agent_id + WHERE pr.parent_agent_id = $1 + AND a.parent_agent_id = $1 + AND a.deleted_at IS NULL`, + [parentAgentId] + ); + return result.rows.map((r) => r.agentId); +} + export async function listRecentPersonaReviews( pool: Pool, sinceDays: number diff --git a/apps/server/test/agent-archive.test.ts b/apps/server/test/agent-archive.test.ts index ba54df27..2d1024fc 100644 --- a/apps/server/test/agent-archive.test.ts +++ b/apps/server/test/agent-archive.test.ts @@ -33,12 +33,17 @@ vi.mock("../src/shared/git/worktree-status.js", () => ({ }), })); +vi.mock("../src/agents/persona-reviews.js", () => ({ + getReviewChildAgentIds: vi.fn().mockResolvedValue([]), +})); + import { runLifecycleHook } from "../src/agents/lifecycle-hooks.js"; import { cleanupGitWorktree } from "../src/shared/git/worktree.js"; import { getUnmergedChanges, getUncommittedChanges, } from "../src/shared/git/worktree-status.js"; +import { getReviewChildAgentIds } from "../src/agents/persona-reviews.js"; // ── Helpers ───────────────────────────────────────────────────────────── @@ -618,31 +623,26 @@ describe("executeArchive", () => { }); describe("cascade child deletion", () => { - it("deletes child agents found in the database", async () => { + it("deletes review child agents found via persona_reviews", async () => { const parent = makeAgent("parent"); const child = makeAgent("child", { parentAgentId: "parent", status: "stopped", }); - let queryCallCount = 0; - const queryImpl = async (sql: string) => { - queryCallCount++; + vi.mocked(getReviewChildAgentIds) + .mockResolvedValueOnce(["child"]) + .mockResolvedValue([]); + + const pool = makePool(async (sql: string) => { if (sql.includes("INSERT INTO agent_events")) { return { rows: [], rowCount: 0 }; } - if (sql.includes("SELECT id FROM agents WHERE parent_agent_id")) { - if (sql.includes("parent_agent_id = $1") && queryCallCount <= 4) { - return { rows: [{ id: "child" }], rowCount: 1 }; - } - return { rows: [], rowCount: 0 }; - } if (sql.includes("UPDATE agents SET deleted_at")) { return { rows: [], rowCount: 1 }; } return { rows: [], rowCount: 0 }; - }; - const pool = makePool(queryImpl); + }); const getRequiredAgent = vi .fn() .mockImplementation(async (id: string) => { @@ -662,18 +662,40 @@ describe("executeArchive", () => { expect(cb.onComplete).toHaveBeenCalledWith(["parent", "child"]); }); - it("continues if a child cascade fails", async () => { + it("does not cascade to non-review child agents", async () => { const parent = makeAgent("parent"); - let queryCallCount = 0; - const queryImpl = async (sql: string) => { - queryCallCount++; - if (sql.includes("SELECT id FROM agents WHERE parent_agent_id")) { - if (queryCallCount <= 4) { - return { rows: [{ id: "bad-child" }], rowCount: 1 }; - } + vi.mocked(getReviewChildAgentIds).mockResolvedValue([]); + + const pool = makePool(async (sql: string) => { + if (sql.includes("INSERT INTO agent_events")) { return { rows: [], rowCount: 0 }; } + if (sql.includes("UPDATE agents SET deleted_at")) { + return { rows: [], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }); + const deps = makeDeps({ + pool: pool as never, + getRequiredAgent: vi.fn().mockResolvedValue(parent), + getAgent: vi.fn().mockResolvedValue(parent), + }); + const cb = makeCallbacks(); + + await executeArchive(deps, "parent", cb); + + expect(cb.onComplete).toHaveBeenCalledWith(["parent"]); + }); + + it("continues if a child cascade fails", async () => { + const parent = makeAgent("parent"); + + vi.mocked(getReviewChildAgentIds) + .mockResolvedValueOnce(["bad-child"]) + .mockResolvedValue([]); + + const pool = makePool(async (sql: string) => { if (sql.includes("INSERT INTO agent_events")) { return { rows: [], rowCount: 0 }; } @@ -681,8 +703,7 @@ describe("executeArchive", () => { return { rows: [], rowCount: 1 }; } return { rows: [], rowCount: 0 }; - }; - const pool = makePool(queryImpl); + }); const deps = makeDeps({ pool: pool as never, getRequiredAgent: vi.fn().mockImplementation(async (id: string) => { @@ -893,23 +914,15 @@ describe("deleteAgentDirect", () => { ); }); - it("cascades to child agents recursively", async () => { + it("cascades to review child agents recursively", async () => { const parent = makeAgent("p1", { status: "stopped" }); const child = makeAgent("c1", { status: "stopped", parentAgentId: "p1" }); - let childQueryCount = 0; - const queryImpl = async (sql: string, params?: unknown[]) => { - if (sql.includes("SELECT id FROM agents WHERE parent_agent_id")) { - const targetId = (params as string[])?.[0]; - if (targetId === "p1" && childQueryCount === 0) { - childQueryCount++; - return { rows: [{ id: "c1" }], rowCount: 1 }; - } - return { rows: [], rowCount: 0 }; - } - return { rows: [], rowCount: 0 }; - }; - const pool = makePool(queryImpl); + vi.mocked(getReviewChildAgentIds) + .mockResolvedValueOnce(["c1"]) + .mockResolvedValue([]); + + const pool = makePool(async () => ({ rows: [], rowCount: 0 })); const deps = makeDeps({ pool: pool as never, getRequiredAgent: vi.fn().mockImplementation(async (id: string) => { @@ -948,19 +961,11 @@ describe("deleteAgentDirect", () => { parentAgentId: "p1", }); - let childQueryDone = false; - const queryImpl = async (sql: string, params?: unknown[]) => { - if (sql.includes("SELECT id FROM agents WHERE parent_agent_id")) { - const targetId = (params as string[])?.[0]; - if (targetId === "p1" && !childQueryDone) { - childQueryDone = true; - return { rows: [{ id: "c1" }], rowCount: 1 }; - } - return { rows: [], rowCount: 0 }; - } - return { rows: [], rowCount: 0 }; - }; - const pool = makePool(queryImpl); + vi.mocked(getReviewChildAgentIds) + .mockResolvedValueOnce(["c1"]) + .mockResolvedValue([]); + + const pool = makePool(async () => ({ rows: [], rowCount: 0 })); const deps = makeDeps({ pool: pool as never, getRequiredAgent: vi.fn().mockImplementation(async (id: string) => {