Add worktree-aware workspace runtime support

This commit is contained in:
Dotta
2026-03-10 10:58:38 -05:00
parent 7934952a77
commit 3120c72372
35 changed files with 8750 additions and 61 deletions

View File

@@ -23,6 +23,14 @@ import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { secretService } from "./secrets.js";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import {
buildWorkspaceReadyComment,
ensureRuntimeServicesForRun,
persistAdapterManagedRuntimeServices,
realizeExecutionWorkspace,
releaseRuntimeServicesForRun,
} from "./workspace-runtime.js";
import { issueService } from "./issues.js";
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
@@ -406,6 +414,7 @@ function resolveNextSessionState(input: {
export function heartbeatService(db: Db) {
const runLogStore = getRunLogStore();
const secretsSvc = secretService(db);
const issuesSvc = issueService(db);
async function getAgent(agentId: string) {
return db
@@ -1099,14 +1108,54 @@ export function heartbeatService(db: Db) {
previousSessionParams,
{ useProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null },
);
const config = parseObject(agent.adapterConfig);
const mergedConfig = issueAssigneeOverrides?.adapterConfig
? { ...config, ...issueAssigneeOverrides.adapterConfig }
: config;
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
mergedConfig,
);
const issueRef = issueId
? await db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
.then((rows) => rows[0] ?? null)
: null;
const executionWorkspace = await realizeExecutionWorkspace({
base: {
baseCwd: resolvedWorkspace.cwd,
source: resolvedWorkspace.source,
projectId: resolvedWorkspace.projectId,
workspaceId: resolvedWorkspace.workspaceId,
repoUrl: resolvedWorkspace.repoUrl,
repoRef: resolvedWorkspace.repoRef,
},
config: resolvedConfig,
issue: issueRef,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
});
const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({
agentId: agent.id,
previousSessionParams,
resolvedWorkspace,
resolvedWorkspace: {
...resolvedWorkspace,
cwd: executionWorkspace.cwd,
},
});
const runtimeSessionParams = runtimeSessionResolution.sessionParams;
const runtimeWorkspaceWarnings = [
...resolvedWorkspace.warnings,
...executionWorkspace.warnings,
...(runtimeSessionResolution.warning ? [runtimeSessionResolution.warning] : []),
...(resetTaskSession && sessionResetReason
? [
@@ -1117,16 +1166,32 @@ export function heartbeatService(db: Db) {
: []),
];
context.paperclipWorkspace = {
cwd: resolvedWorkspace.cwd,
source: resolvedWorkspace.source,
projectId: resolvedWorkspace.projectId,
workspaceId: resolvedWorkspace.workspaceId,
repoUrl: resolvedWorkspace.repoUrl,
repoRef: resolvedWorkspace.repoRef,
cwd: executionWorkspace.cwd,
source: executionWorkspace.source,
strategy: executionWorkspace.strategy,
projectId: executionWorkspace.projectId,
workspaceId: executionWorkspace.workspaceId,
repoUrl: executionWorkspace.repoUrl,
repoRef: executionWorkspace.repoRef,
branchName: executionWorkspace.branchName,
worktreePath: executionWorkspace.worktreePath,
};
context.paperclipWorkspaces = resolvedWorkspace.workspaceHints;
if (resolvedWorkspace.projectId && !readNonEmptyString(context.projectId)) {
context.projectId = resolvedWorkspace.projectId;
const runtimeServiceIntents = (() => {
const runtimeConfig = parseObject(resolvedConfig.workspaceRuntime);
return Array.isArray(runtimeConfig.services)
? runtimeConfig.services.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
})();
if (runtimeServiceIntents.length > 0) {
context.paperclipRuntimeServiceIntents = runtimeServiceIntents;
} else {
delete context.paperclipRuntimeServiceIntents;
}
if (executionWorkspace.projectId && !readNonEmptyString(context.projectId)) {
context.projectId = executionWorkspace.projectId;
}
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
const previousSessionDisplayId = truncateDisplayId(
@@ -1146,7 +1211,6 @@ export function heartbeatService(db: Db) {
let handle: RunLogHandle | null = null;
let stdoutExcerpt = "";
let stderrExcerpt = "";
try {
const startedAt = run.startedAt ?? new Date();
const runningWithSession = await db
@@ -1154,6 +1218,7 @@ export function heartbeatService(db: Db) {
.set({
startedAt,
sessionIdBefore: runtimeForAdapter.sessionDisplayId ?? runtimeForAdapter.sessionId,
contextSnapshot: context,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, run.id))
@@ -1235,15 +1300,54 @@ 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
? { ...config, ...issueAssigneeOverrides.adapterConfig }
: config;
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
mergedConfig,
const adapterEnv = Object.fromEntries(
Object.entries(parseObject(resolvedConfig.env)).filter(
(entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string",
),
);
const runtimeServices = await ensureRuntimeServicesForRun({
db,
runId: run.id,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
issue: issueRef,
workspace: executionWorkspace,
config: resolvedConfig,
adapterEnv,
onLog,
});
if (runtimeServices.length > 0) {
context.paperclipRuntimeServices = runtimeServices;
context.paperclipRuntimePrimaryUrl =
runtimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null;
await db
.update(heartbeatRuns)
.set({
contextSnapshot: context,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, run.id));
}
if (issueId && (executionWorkspace.created || runtimeServices.some((service) => !service.reused))) {
try {
await issuesSvc.addComment(
issueId,
buildWorkspaceReadyComment({
workspace: executionWorkspace,
runtimeServices,
}),
{ agentId: agent.id },
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to post workspace-ready comment: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
if (meta.env && secretKeys.size > 0) {
for (const key of secretKeys) {
@@ -1284,6 +1388,54 @@ export function heartbeatService(db: Db) {
onMeta: onAdapterMeta,
authToken: authToken ?? undefined,
});
const adapterManagedRuntimeServices = adapterResult.runtimeServices
? await persistAdapterManagedRuntimeServices({
db,
adapterType: agent.adapterType,
runId: run.id,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
issue: issueRef,
workspace: executionWorkspace,
reports: adapterResult.runtimeServices,
})
: [];
if (adapterManagedRuntimeServices.length > 0) {
const combinedRuntimeServices = [
...runtimeServices,
...adapterManagedRuntimeServices,
];
context.paperclipRuntimeServices = combinedRuntimeServices;
context.paperclipRuntimePrimaryUrl =
combinedRuntimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null;
await db
.update(heartbeatRuns)
.set({
contextSnapshot: context,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, run.id));
if (issueId) {
try {
await issuesSvc.addComment(
issueId,
buildWorkspaceReadyComment({
workspace: executionWorkspace,
runtimeServices: adapterManagedRuntimeServices,
}),
{ agentId: agent.id },
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to post adapter-managed runtime comment: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
const nextSessionState = resolveNextSessionState({
codec: sessionCodec,
adapterResult,
@@ -1460,6 +1612,7 @@ export function heartbeatService(db: Db) {
await finalizeAgentStatus(agent.id, "failed");
} finally {
await releaseRuntimeServicesForRun(run.id);
await startNextQueuedRunForAgent(agent.id);
}
}