Improve workspace fallback logging and session resume migration

This commit is contained in:
Dotta
2026-03-05 06:14:32 -06:00
parent 1f57577c54
commit b4a02ebc3f
2 changed files with 224 additions and 5 deletions

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import { resolveRuntimeSessionParamsForWorkspace, type ResolvedWorkspaceForRun } from "../services/heartbeat.ts";
function buildResolvedWorkspace(overrides: Partial<ResolvedWorkspaceForRun> = {}): ResolvedWorkspaceForRun {
return {
cwd: "/tmp/project",
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: null,
workspaceHints: [],
warnings: [],
...overrides,
};
}
describe("resolveRuntimeSessionParamsForWorkspace", () => {
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
const agentId = "agent-123";
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
const result = resolveRuntimeSessionParamsForWorkspace({
agentId,
previousSessionParams: {
sessionId: "session-1",
cwd: fallbackCwd,
workspaceId: "workspace-1",
},
resolvedWorkspace: buildResolvedWorkspace({ cwd: "/tmp/new-project-cwd" }),
});
expect(result.sessionParams).toMatchObject({
sessionId: "session-1",
cwd: "/tmp/new-project-cwd",
workspaceId: "workspace-1",
});
expect(result.warning).toContain("Attempting to resume session");
});
it("does not migrate when previous session cwd is not the fallback workspace", () => {
const result = resolveRuntimeSessionParamsForWorkspace({
agentId: "agent-123",
previousSessionParams: {
sessionId: "session-1",
cwd: "/tmp/some-other-cwd",
workspaceId: "workspace-1",
},
resolvedWorkspace: buildResolvedWorkspace({ cwd: "/tmp/new-project-cwd" }),
});
expect(result.sessionParams).toEqual({
sessionId: "session-1",
cwd: "/tmp/some-other-cwd",
workspaceId: "workspace-1",
});
expect(result.warning).toBeNull();
});
it("does not migrate when resolved workspace id differs from previous session workspace id", () => {
const agentId = "agent-123";
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
const result = resolveRuntimeSessionParamsForWorkspace({
agentId,
previousSessionParams: {
sessionId: "session-1",
cwd: fallbackCwd,
workspaceId: "workspace-1",
},
resolvedWorkspace: buildResolvedWorkspace({
cwd: "/tmp/new-project-cwd",
workspaceId: "workspace-2",
}),
});
expect(result.sessionParams).toEqual({
sessionId: "session-1",
cwd: fallbackCwd,
workspaceId: "workspace-1",
});
expect(result.warning).toBeNull();
});
});

View File

@@ -1,4 +1,5 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path";
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm"; import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { import {
@@ -73,10 +74,94 @@ interface ParsedIssueAssigneeAdapterOverrides {
useProjectWorkspace: boolean | null; useProjectWorkspace: boolean | null;
} }
export type ResolvedWorkspaceForRun = {
cwd: string;
source: "project_primary" | "task_session" | "agent_home";
projectId: string | null;
workspaceId: string | null;
repoUrl: string | null;
repoRef: string | null;
workspaceHints: Array<{
workspaceId: string;
cwd: string | null;
repoUrl: string | null;
repoRef: string | null;
}>;
warnings: string[];
};
function readNonEmptyString(value: unknown): string | null { function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null; return typeof value === "string" && value.trim().length > 0 ? value : null;
} }
export function resolveRuntimeSessionParamsForWorkspace(input: {
agentId: string;
previousSessionParams: Record<string, unknown> | null;
resolvedWorkspace: ResolvedWorkspaceForRun;
}) {
const { agentId, previousSessionParams, resolvedWorkspace } = input;
const previousSessionId = readNonEmptyString(previousSessionParams?.sessionId);
const previousCwd = readNonEmptyString(previousSessionParams?.cwd);
if (!previousSessionId || !previousCwd) {
return {
sessionParams: previousSessionParams,
warning: null as string | null,
};
}
if (resolvedWorkspace.source !== "project_primary") {
return {
sessionParams: previousSessionParams,
warning: null as string | null,
};
}
const projectCwd = readNonEmptyString(resolvedWorkspace.cwd);
if (!projectCwd) {
return {
sessionParams: previousSessionParams,
warning: null as string | null,
};
}
const fallbackAgentHomeCwd = resolveDefaultAgentWorkspaceDir(agentId);
if (path.resolve(previousCwd) !== path.resolve(fallbackAgentHomeCwd)) {
return {
sessionParams: previousSessionParams,
warning: null as string | null,
};
}
if (path.resolve(projectCwd) === path.resolve(previousCwd)) {
return {
sessionParams: previousSessionParams,
warning: null as string | null,
};
}
const previousWorkspaceId = readNonEmptyString(previousSessionParams?.workspaceId);
if (
previousWorkspaceId &&
resolvedWorkspace.workspaceId &&
previousWorkspaceId !== resolvedWorkspace.workspaceId
) {
return {
sessionParams: previousSessionParams,
warning: null as string | null,
};
}
const migratedSessionParams: Record<string, unknown> = {
...(previousSessionParams ?? {}),
cwd: projectCwd,
};
if (resolvedWorkspace.workspaceId) migratedSessionParams.workspaceId = resolvedWorkspace.workspaceId;
if (resolvedWorkspace.repoUrl) migratedSessionParams.repoUrl = resolvedWorkspace.repoUrl;
if (resolvedWorkspace.repoRef) migratedSessionParams.repoRef = resolvedWorkspace.repoRef;
return {
sessionParams: migratedSessionParams,
warning:
`Project workspace "${projectCwd}" is now available. ` +
`Attempting to resume session "${previousSessionId}" that was previously saved in fallback workspace "${previousCwd}".`,
};
}
function parseIssueAssigneeAdapterOverrides( function parseIssueAssigneeAdapterOverrides(
raw: unknown, raw: unknown,
): ParsedIssueAssigneeAdapterOverrides | null { ): ParsedIssueAssigneeAdapterOverrides | null {
@@ -368,7 +453,7 @@ export function heartbeatService(db: Db) {
context: Record<string, unknown>, context: Record<string, unknown>,
previousSessionParams: Record<string, unknown> | null, previousSessionParams: Record<string, unknown> | null,
opts?: { useProjectWorkspace?: boolean | null }, opts?: { useProjectWorkspace?: boolean | null },
) { ): Promise<ResolvedWorkspaceForRun> {
const issueId = readNonEmptyString(context.issueId); const issueId = readNonEmptyString(context.issueId);
const contextProjectId = readNonEmptyString(context.projectId); const contextProjectId = readNonEmptyString(context.projectId);
const issueProjectId = issueId const issueProjectId = issueId
@@ -403,11 +488,14 @@ export function heartbeatService(db: Db) {
})); }));
if (projectWorkspaceRows.length > 0) { if (projectWorkspaceRows.length > 0) {
const missingProjectCwds: string[] = [];
let hasConfiguredProjectCwd = false;
for (const workspace of projectWorkspaceRows) { for (const workspace of projectWorkspaceRows) {
const projectCwd = readNonEmptyString(workspace.cwd); const projectCwd = readNonEmptyString(workspace.cwd);
if (!projectCwd || projectCwd === REPO_ONLY_CWD_SENTINEL) { if (!projectCwd || projectCwd === REPO_ONLY_CWD_SENTINEL) {
continue; continue;
} }
hasConfiguredProjectCwd = true;
const projectCwdExists = await fs const projectCwdExists = await fs
.stat(projectCwd) .stat(projectCwd)
.then((stats) => stats.isDirectory()) .then((stats) => stats.isDirectory())
@@ -421,12 +509,28 @@ export function heartbeatService(db: Db) {
repoUrl: workspace.repoUrl, repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef, repoRef: workspace.repoRef,
workspaceHints, workspaceHints,
warnings: [],
}; };
} }
missingProjectCwds.push(projectCwd);
} }
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agent.id); const fallbackCwd = resolveDefaultAgentWorkspaceDir(agent.id);
await fs.mkdir(fallbackCwd, { recursive: true }); await fs.mkdir(fallbackCwd, { recursive: true });
const warnings: string[] = [];
if (missingProjectCwds.length > 0) {
const firstMissing = missingProjectCwds[0];
const extraMissingCount = Math.max(0, missingProjectCwds.length - 1);
warnings.push(
extraMissingCount > 0
? `Project workspace path "${firstMissing}" and ${extraMissingCount} other configured path(s) are not available yet. Using fallback workspace "${fallbackCwd}" for this run.`
: `Project workspace path "${firstMissing}" is not available yet. Using fallback workspace "${fallbackCwd}" for this run.`,
);
} else if (!hasConfiguredProjectCwd) {
warnings.push(
`Project workspace has no local cwd configured. Using fallback workspace "${fallbackCwd}" for this run.`,
);
}
return { return {
cwd: fallbackCwd, cwd: fallbackCwd,
source: "project_primary" as const, source: "project_primary" as const,
@@ -435,6 +539,7 @@ export function heartbeatService(db: Db) {
repoUrl: projectWorkspaceRows[0]?.repoUrl ?? null, repoUrl: projectWorkspaceRows[0]?.repoUrl ?? null,
repoRef: projectWorkspaceRows[0]?.repoRef ?? null, repoRef: projectWorkspaceRows[0]?.repoRef ?? null,
workspaceHints, workspaceHints,
warnings,
}; };
} }
@@ -453,12 +558,27 @@ export function heartbeatService(db: Db) {
repoUrl: readNonEmptyString(previousSessionParams?.repoUrl), repoUrl: readNonEmptyString(previousSessionParams?.repoUrl),
repoRef: readNonEmptyString(previousSessionParams?.repoRef), repoRef: readNonEmptyString(previousSessionParams?.repoRef),
workspaceHints, workspaceHints,
warnings: [],
}; };
} }
} }
const cwd = resolveDefaultAgentWorkspaceDir(agent.id); const cwd = resolveDefaultAgentWorkspaceDir(agent.id);
await fs.mkdir(cwd, { recursive: true }); await fs.mkdir(cwd, { recursive: true });
const warnings: string[] = [];
if (sessionCwd) {
warnings.push(
`Saved session workspace "${sessionCwd}" is not available. Using fallback workspace "${cwd}" for this run.`,
);
} else if (resolvedProjectId) {
warnings.push(
`No project workspace directory is currently available for this issue. Using fallback workspace "${cwd}" for this run.`,
);
} else {
warnings.push(
`No project or prior session workspace was available. Using fallback workspace "${cwd}" for this run.`,
);
}
return { return {
cwd, cwd,
source: "agent_home" as const, source: "agent_home" as const,
@@ -467,6 +587,7 @@ export function heartbeatService(db: Db) {
repoUrl: null, repoUrl: null,
repoRef: null, repoRef: null,
workspaceHints, workspaceHints,
warnings,
}; };
} }
@@ -946,6 +1067,16 @@ export function heartbeatService(db: Db) {
previousSessionParams, previousSessionParams,
{ useProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null }, { useProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null },
); );
const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({
agentId: agent.id,
previousSessionParams,
resolvedWorkspace,
});
const runtimeSessionParams = runtimeSessionResolution.sessionParams;
const runtimeWorkspaceWarnings = [
...resolvedWorkspace.warnings,
...(runtimeSessionResolution.warning ? [runtimeSessionResolution.warning] : []),
];
context.paperclipWorkspace = { context.paperclipWorkspace = {
cwd: resolvedWorkspace.cwd, cwd: resolvedWorkspace.cwd,
source: resolvedWorkspace.source, source: resolvedWorkspace.source,
@@ -961,13 +1092,13 @@ export function heartbeatService(db: Db) {
const runtimeSessionFallback = taskKey ? null : runtime.sessionId; const runtimeSessionFallback = taskKey ? null : runtime.sessionId;
const previousSessionDisplayId = truncateDisplayId( const previousSessionDisplayId = truncateDisplayId(
taskSession?.sessionDisplayId ?? taskSession?.sessionDisplayId ??
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(previousSessionParams) : null) ?? (sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ??
readNonEmptyString(previousSessionParams?.sessionId) ?? readNonEmptyString(runtimeSessionParams?.sessionId) ??
runtimeSessionFallback, runtimeSessionFallback,
); );
const runtimeForAdapter = { const runtimeForAdapter = {
sessionId: readNonEmptyString(previousSessionParams?.sessionId) ?? runtimeSessionFallback, sessionId: readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback,
sessionParams: previousSessionParams, sessionParams: runtimeSessionParams,
sessionDisplayId: previousSessionDisplayId, sessionDisplayId: previousSessionDisplayId,
taskKey, taskKey,
}; };
@@ -1062,6 +1193,9 @@ export function heartbeatService(db: Db) {
}, },
}); });
}; };
for (const warning of runtimeWorkspaceWarnings) {
await onLog("stderr", `[paperclip] ${warning}\n`);
}
const config = parseObject(agent.adapterConfig); const config = parseObject(agent.adapterConfig);
const mergedConfig = issueAssigneeOverrides?.adapterConfig const mergedConfig = issueAssigneeOverrides?.adapterConfig