From b4a02ebc3f9d064f174ea335db344828a2c78f3c Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 06:14:32 -0600 Subject: [PATCH] Improve workspace fallback logging and session resume migration --- .../heartbeat-workspace-session.test.ts | 85 +++++++++++ server/src/services/heartbeat.ts | 144 +++++++++++++++++- 2 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 server/src/__tests__/heartbeat-workspace-session.test.ts diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts new file mode 100644 index 00000000..22aba591 --- /dev/null +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -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 { + 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(); + }); +}); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 116431fb..b7396d7f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import path from "node:path"; import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { @@ -73,10 +74,94 @@ interface ParsedIssueAssigneeAdapterOverrides { 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 { return typeof value === "string" && value.trim().length > 0 ? value : null; } +export function resolveRuntimeSessionParamsForWorkspace(input: { + agentId: string; + previousSessionParams: Record | 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 = { + ...(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( raw: unknown, ): ParsedIssueAssigneeAdapterOverrides | null { @@ -368,7 +453,7 @@ export function heartbeatService(db: Db) { context: Record, previousSessionParams: Record | null, opts?: { useProjectWorkspace?: boolean | null }, - ) { + ): Promise { const issueId = readNonEmptyString(context.issueId); const contextProjectId = readNonEmptyString(context.projectId); const issueProjectId = issueId @@ -403,11 +488,14 @@ export function heartbeatService(db: Db) { })); if (projectWorkspaceRows.length > 0) { + const missingProjectCwds: string[] = []; + let hasConfiguredProjectCwd = false; for (const workspace of projectWorkspaceRows) { const projectCwd = readNonEmptyString(workspace.cwd); if (!projectCwd || projectCwd === REPO_ONLY_CWD_SENTINEL) { continue; } + hasConfiguredProjectCwd = true; const projectCwdExists = await fs .stat(projectCwd) .then((stats) => stats.isDirectory()) @@ -421,12 +509,28 @@ export function heartbeatService(db: Db) { repoUrl: workspace.repoUrl, repoRef: workspace.repoRef, workspaceHints, + warnings: [], }; } + missingProjectCwds.push(projectCwd); } const fallbackCwd = resolveDefaultAgentWorkspaceDir(agent.id); 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 { cwd: fallbackCwd, source: "project_primary" as const, @@ -435,6 +539,7 @@ export function heartbeatService(db: Db) { repoUrl: projectWorkspaceRows[0]?.repoUrl ?? null, repoRef: projectWorkspaceRows[0]?.repoRef ?? null, workspaceHints, + warnings, }; } @@ -453,12 +558,27 @@ export function heartbeatService(db: Db) { repoUrl: readNonEmptyString(previousSessionParams?.repoUrl), repoRef: readNonEmptyString(previousSessionParams?.repoRef), workspaceHints, + warnings: [], }; } } const cwd = resolveDefaultAgentWorkspaceDir(agent.id); 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 { cwd, source: "agent_home" as const, @@ -467,6 +587,7 @@ export function heartbeatService(db: Db) { repoUrl: null, repoRef: null, workspaceHints, + warnings, }; } @@ -946,6 +1067,16 @@ export function heartbeatService(db: Db) { previousSessionParams, { 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 = { cwd: resolvedWorkspace.cwd, source: resolvedWorkspace.source, @@ -961,13 +1092,13 @@ export function heartbeatService(db: Db) { const runtimeSessionFallback = taskKey ? null : runtime.sessionId; const previousSessionDisplayId = truncateDisplayId( taskSession?.sessionDisplayId ?? - (sessionCodec.getDisplayId ? sessionCodec.getDisplayId(previousSessionParams) : null) ?? - readNonEmptyString(previousSessionParams?.sessionId) ?? + (sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ?? + readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback, ); const runtimeForAdapter = { - sessionId: readNonEmptyString(previousSessionParams?.sessionId) ?? runtimeSessionFallback, - sessionParams: previousSessionParams, + sessionId: readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback, + sessionParams: runtimeSessionParams, sessionDisplayId: previousSessionDisplayId, 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 mergedConfig = issueAssigneeOverrides?.adapterConfig