Clarify manual workspace runtime behavior

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-29 10:49:49 -05:00
parent d9005405b9
commit b3d61a7561
9 changed files with 309 additions and 68 deletions
@@ -3,11 +3,13 @@ import type { agents } from "@paperclipai/db";
import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import {
applyPersistedExecutionWorkspaceConfig,
buildExplicitResumeSessionOverride,
formatRuntimeWorkspaceWarningLog,
prioritizeProjectWorkspaceCandidatesForRun,
parseSessionCompactionPolicy,
resolveRuntimeSessionParamsForWorkspace,
stripWorkspaceRuntimeFromExecutionRunConfig,
shouldResetTaskSessionForWake,
type ResolvedWorkspaceForRun,
} from "../services/heartbeat.ts";
@@ -120,6 +122,64 @@ describe("resolveRuntimeSessionParamsForWorkspace", () => {
});
});
describe("applyPersistedExecutionWorkspaceConfig", () => {
it("does not add workspace runtime when only the project workspace had manual runtime config", () => {
const result = applyPersistedExecutionWorkspaceConfig({
config: {},
workspaceConfig: null,
mode: "isolated_workspace",
});
expect("workspaceRuntime" in result).toBe(false);
});
it("applies explicit persisted execution workspace runtime config when present", () => {
const result = applyPersistedExecutionWorkspaceConfig({
config: {},
workspaceConfig: {
provisionCommand: null,
teardownCommand: null,
cleanupCommand: null,
desiredState: null,
workspaceRuntime: {
services: [{ name: "workspace-web" }],
},
},
mode: "isolated_workspace",
});
expect(result.workspaceRuntime).toEqual({
services: [{ name: "workspace-web" }],
});
});
});
describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => {
it("removes workspace runtime before heartbeat execution", () => {
const input = {
cwd: "/tmp/project",
workspaceStrategy: {
type: "git_worktree",
},
workspaceRuntime: {
services: [{ name: "web" }],
},
};
const result = stripWorkspaceRuntimeFromExecutionRunConfig(input);
expect(result).toEqual({
cwd: "/tmp/project",
workspaceStrategy: {
type: "git_worktree",
},
});
expect(input.workspaceRuntime).toEqual({
services: [{ name: "web" }],
});
});
});
describe("shouldResetTaskSessionForWake", () => {
it("resets session context on assignment wake", () => {
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true);
+1 -10
View File
@@ -28,7 +28,7 @@ import { createApp } from "./app.js";
import { loadConfig } from "./config.js";
import { logger } from "./middleware/logger.js";
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, restartDesiredRuntimeServicesOnStartup, routineService } from "./services/index.js";
import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, routineService } from "./services/index.js";
import { createStorageServiceFromConfig } from "./storage/index.js";
import { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
@@ -557,15 +557,6 @@ export async function startServer(): Promise<StartedServer> {
"reconciled persisted runtime services from a previous server process",
);
}
return restartDesiredRuntimeServicesOnStartup(db as any);
})
.then((result) => {
if (result && result.restarted > 0) {
logger.warn(
{ restarted: result.restarted, failed: result.failed },
"restarted desired workspace runtime services on startup",
);
}
})
.catch((err) => {
logger.error({ err }, "startup reconciliation of persisted runtime services failed");
+9 -23
View File
@@ -51,7 +51,6 @@ import {
resolveExecutionWorkspaceMode,
} from "./execution-workspace-policy.js";
import { instanceSettingsService } from "./instance-settings.js";
import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
import {
hasSessionCompactionThresholds,
@@ -77,10 +76,9 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
"pi_local",
]);
function applyPersistedExecutionWorkspaceConfig(input: {
export function applyPersistedExecutionWorkspaceConfig(input: {
config: Record<string, unknown>;
workspaceConfig: ExecutionWorkspaceConfig | null;
projectWorkspaceRuntime: Record<string, unknown> | null;
mode: ReturnType<typeof resolveExecutionWorkspaceMode>;
}) {
const nextConfig = { ...input.config };
@@ -90,8 +88,6 @@ function applyPersistedExecutionWorkspaceConfig(input: {
delete nextConfig.workspaceRuntime;
} else if (input.workspaceConfig?.workspaceRuntime) {
nextConfig.workspaceRuntime = { ...input.workspaceConfig.workspaceRuntime };
} else if (input.projectWorkspaceRuntime) {
nextConfig.workspaceRuntime = { ...input.projectWorkspaceRuntime };
}
}
@@ -107,6 +103,12 @@ function applyPersistedExecutionWorkspaceConfig(input: {
return nextConfig;
}
export function stripWorkspaceRuntimeFromExecutionRunConfig(config: Record<string, unknown>) {
const nextConfig = { ...config };
delete nextConfig.workspaceRuntime;
return nextConfig;
}
function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>): Partial<ExecutionWorkspaceConfig> | null {
const strategy = parseObject(config.workspaceStrategy);
const snapshot: Partial<ExecutionWorkspaceConfig> = {};
@@ -2114,35 +2116,19 @@ export function heartbeatService(db: Db) {
: null;
const existingExecutionWorkspace =
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
const resolvedProjectWorkspace =
resolvedWorkspace.workspaceId
? await db
.select({ metadata: projectWorkspaces.metadata })
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.id, resolvedWorkspace.workspaceId),
eq(projectWorkspaces.companyId, agent.companyId),
),
)
.then((rows) => rows[0] ?? null)
: null;
const projectWorkspaceRuntimeConfig = readProjectWorkspaceRuntimeConfig(
(resolvedProjectWorkspace?.metadata as Record<string, unknown> | null) ?? null,
);
const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({
config: workspaceManagedConfig,
workspaceConfig: existingExecutionWorkspace?.config ?? null,
projectWorkspaceRuntime: projectWorkspaceRuntimeConfig?.workspaceRuntime ?? null,
mode: executionWorkspaceMode,
});
const mergedConfig = issueAssigneeOverrides?.adapterConfig
? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
: persistedWorkspaceManagedConfig;
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig);
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
mergedConfig,
executionRunConfig,
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const runtimeConfig = {