Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 13 additions & 18 deletions apps/server/src/agents/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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");
Expand Down Expand Up @@ -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"
);
}
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/agents/persona-reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,22 @@ export async function getPersonaReviewsByParent(
return result.rows;
}

export async function getReviewChildAgentIds(
pool: Pool,
parentAgentId: string
): Promise<string[]> {
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
Expand Down
103 changes: 54 additions & 49 deletions apps/server/test/agent-archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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) => {
Expand All @@ -662,27 +662,48 @@ 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 };
}
if (sql.includes("UPDATE agents SET deleted_at")) {
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) => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down