mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Keep agent lists working when project records go stale (#1812)
* fix(server): keep agent fetch RPCs resilient Agent directory projection assumed every workspace still had a parent project. A stale workspace/project record could reject the whole fetch_agents RPC, so missing placement now behaves like no placement and direct fetches can still return the agent. * test(server): cover stale agent history fetches
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
|
||||
const CREATED_AT = "2026-06-29T11:12:42.000Z";
|
||||
const HEALTHY_UPDATED_AT = "2026-06-29T11:40:00.000Z";
|
||||
const ORPHAN_ARCHIVED_AT = "2026-06-29T11:35:35.000Z";
|
||||
|
||||
interface StaleAgentFixture {
|
||||
healthyProjectId: string;
|
||||
healthyWorkspaceId: string;
|
||||
orphanWorkspaceId: string;
|
||||
healthyAgentId: string;
|
||||
orphanAgentId: string;
|
||||
paseoHomeRoot: string;
|
||||
cleanupPaths: string[];
|
||||
}
|
||||
|
||||
test("agent fetch RPCs tolerate an agent whose workspace project record is gone", async () => {
|
||||
const fixture = seedStaleAgentFixture();
|
||||
let daemon: TestPaseoDaemon | null = null;
|
||||
let client: DaemonClient | null = null;
|
||||
|
||||
try {
|
||||
daemon = await createTestPaseoDaemon({ paseoHomeRoot: fixture.paseoHomeRoot, cleanup: false });
|
||||
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
await client.connect();
|
||||
|
||||
const agents = await client.fetchAgents({
|
||||
requestId: "req-agent-rpc-list",
|
||||
filter: { includeArchived: true },
|
||||
});
|
||||
const history = await client.fetchAgentHistory({
|
||||
requestId: "req-agent-rpc-history",
|
||||
});
|
||||
const orphanAgent = await client.fetchAgent({
|
||||
requestId: "req-agent-rpc-detail",
|
||||
agentId: fixture.orphanAgentId,
|
||||
});
|
||||
|
||||
expect(agents.entries.map(toAgentEntrySummary)).toEqual([healthyAgentSummary(fixture)]);
|
||||
expect(agents.pageInfo).toEqual({
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
expect(history.entries.map(toAgentEntrySummary)).toEqual([healthyAgentSummary(fixture)]);
|
||||
expect(history.pageInfo).toEqual({
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
expect({
|
||||
agentId: orphanAgent?.agent.id,
|
||||
workspaceId: orphanAgent?.agent.workspaceId,
|
||||
archivedAt: orphanAgent?.agent.archivedAt,
|
||||
project: orphanAgent?.project,
|
||||
}).toEqual({
|
||||
agentId: fixture.orphanAgentId,
|
||||
workspaceId: fixture.orphanWorkspaceId,
|
||||
archivedAt: ORPHAN_ARCHIVED_AT,
|
||||
project: null,
|
||||
});
|
||||
} finally {
|
||||
await client?.close().catch(() => undefined);
|
||||
await daemon?.close().catch(() => undefined);
|
||||
for (const target of fixture.cleanupPaths) {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function seedStaleAgentFixture(): StaleAgentFixture {
|
||||
const healthyCwd = mkdtempSync(path.join(os.tmpdir(), "paseo-healthy-agent-"));
|
||||
const orphanCwd = mkdtempSync(path.join(os.tmpdir(), "paseo-orphan-agent-"));
|
||||
const paseoHomeRoot = mkdtempSync(path.join(os.tmpdir(), "paseo-orphan-agent-home-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
const projectsDir = path.join(paseoHome, "projects");
|
||||
const agentsDir = path.join(paseoHome, "agents");
|
||||
const healthyProjectId = "proj-healthy-agent-rpc";
|
||||
const healthyWorkspaceId = "ws-healthy-agent-rpc";
|
||||
const orphanWorkspaceId = "c:\\Users\\paseo\\stale-project";
|
||||
const orphanProjectId = "proj-removed-agent-rpc";
|
||||
const healthyAgentId = "agent-healthy-rpc";
|
||||
const orphanAgentId = "agent-orphan-rpc";
|
||||
|
||||
mkdirSync(projectsDir, { recursive: true });
|
||||
mkdirSync(agentsDir, { recursive: true });
|
||||
writeJson(path.join(projectsDir, "projects.json"), [
|
||||
{
|
||||
projectId: healthyProjectId,
|
||||
rootPath: healthyCwd,
|
||||
kind: "non_git",
|
||||
displayName: "healthy",
|
||||
customName: null,
|
||||
createdAt: CREATED_AT,
|
||||
updatedAt: HEALTHY_UPDATED_AT,
|
||||
archivedAt: null,
|
||||
},
|
||||
]);
|
||||
writeJson(path.join(projectsDir, "workspaces.json"), [
|
||||
{
|
||||
workspaceId: healthyWorkspaceId,
|
||||
projectId: healthyProjectId,
|
||||
cwd: healthyCwd,
|
||||
kind: "directory",
|
||||
displayName: "healthy",
|
||||
title: null,
|
||||
branch: null,
|
||||
baseBranch: null,
|
||||
createdAt: CREATED_AT,
|
||||
updatedAt: HEALTHY_UPDATED_AT,
|
||||
archivedAt: null,
|
||||
},
|
||||
{
|
||||
workspaceId: orphanWorkspaceId,
|
||||
projectId: orphanProjectId,
|
||||
cwd: orphanCwd,
|
||||
kind: "directory",
|
||||
displayName: "stale project",
|
||||
title: null,
|
||||
branch: null,
|
||||
baseBranch: null,
|
||||
createdAt: CREATED_AT,
|
||||
updatedAt: ORPHAN_ARCHIVED_AT,
|
||||
archivedAt: ORPHAN_ARCHIVED_AT,
|
||||
},
|
||||
]);
|
||||
writeJson(path.join(agentsDir, `${healthyAgentId}.json`), {
|
||||
id: healthyAgentId,
|
||||
provider: "codex",
|
||||
cwd: healthyCwd,
|
||||
workspaceId: healthyWorkspaceId,
|
||||
createdAt: CREATED_AT,
|
||||
updatedAt: HEALTHY_UPDATED_AT,
|
||||
lastActivityAt: HEALTHY_UPDATED_AT,
|
||||
lastUserMessageAt: null,
|
||||
title: "Healthy Agent",
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
lastModeId: "full-access",
|
||||
config: null,
|
||||
persistence: null,
|
||||
});
|
||||
writeJson(path.join(agentsDir, `${orphanAgentId}.json`), {
|
||||
id: orphanAgentId,
|
||||
provider: "codex",
|
||||
cwd: orphanCwd,
|
||||
workspaceId: orphanWorkspaceId,
|
||||
createdAt: CREATED_AT,
|
||||
updatedAt: ORPHAN_ARCHIVED_AT,
|
||||
lastActivityAt: ORPHAN_ARCHIVED_AT,
|
||||
lastUserMessageAt: null,
|
||||
title: "Orphaned Archived Agent",
|
||||
labels: {},
|
||||
lastStatus: "closed",
|
||||
lastModeId: "full-access",
|
||||
config: null,
|
||||
persistence: null,
|
||||
archivedAt: ORPHAN_ARCHIVED_AT,
|
||||
});
|
||||
|
||||
return {
|
||||
healthyProjectId,
|
||||
healthyWorkspaceId,
|
||||
orphanWorkspaceId,
|
||||
healthyAgentId,
|
||||
orphanAgentId,
|
||||
paseoHomeRoot,
|
||||
cleanupPaths: [healthyCwd, orphanCwd, paseoHomeRoot],
|
||||
};
|
||||
}
|
||||
|
||||
function writeJson(filePath: string, value: unknown): void {
|
||||
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
interface AgentDirectoryEntrySummaryInput {
|
||||
agent: {
|
||||
id: string;
|
||||
workspaceId?: string;
|
||||
archivedAt?: string | null;
|
||||
};
|
||||
project: {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
workspaceName?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function healthyAgentSummary(fixture: StaleAgentFixture) {
|
||||
return {
|
||||
agentId: fixture.healthyAgentId,
|
||||
workspaceId: fixture.healthyWorkspaceId,
|
||||
archivedAt: null,
|
||||
projectKey: fixture.healthyProjectId,
|
||||
projectName: "healthy",
|
||||
workspaceName: "healthy",
|
||||
};
|
||||
}
|
||||
|
||||
function toAgentEntrySummary(entry: AgentDirectoryEntrySummaryInput) {
|
||||
return {
|
||||
agentId: entry.agent.id,
|
||||
workspaceId: entry.agent.workspaceId,
|
||||
archivedAt: entry.agent.archivedAt ?? null,
|
||||
projectKey: entry.project.projectKey,
|
||||
projectName: entry.project.projectName,
|
||||
workspaceName: entry.project.workspaceName ?? null,
|
||||
};
|
||||
}
|
||||
@@ -1298,14 +1298,6 @@ export class Session {
|
||||
): Promise<ProjectPlacementPayload | null> {
|
||||
const workspace = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!workspace) return null;
|
||||
return this.buildProjectPlacementForWorkspace(workspace);
|
||||
}
|
||||
|
||||
private async buildProjectPlacementForExistingWorkspaceProject(
|
||||
workspaceId: string,
|
||||
): Promise<ProjectPlacementPayload | null> {
|
||||
const workspace = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!workspace) return null;
|
||||
|
||||
const project = await this.projectRegistry.get(workspace.projectId);
|
||||
if (!project) return null;
|
||||
@@ -3563,10 +3555,7 @@ export class Session {
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const placementPromise =
|
||||
request.type === "fetch_agent_history_request"
|
||||
? this.buildProjectPlacementForExistingWorkspaceProject(workspaceId)
|
||||
: this.buildProjectPlacementForWorkspaceId(workspaceId);
|
||||
const placementPromise = this.buildProjectPlacementForWorkspaceId(workspaceId);
|
||||
placementByWorkspaceId.set(workspaceId, placementPromise);
|
||||
return placementPromise;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user