Add worktree-aware workspace runtime support
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user