Improve workspace fallback logging and session resume migration
This commit is contained in:
85
server/src/__tests__/heartbeat-workspace-session.test.ts
Normal file
85
server/src/__tests__/heartbeat-workspace-session.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user