feat: add provider-neutral runner events

This commit is contained in:
Dotta committed 2026-08-21 18:36:12 -05:00
1 parent 47b3429a7f
commit 39c4fbe421
87 files changed
+51704 -282

No files matched your search

@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { findServerAdapter } from "../adapters/registry.js";
const CONFIG = {
provider: "claude_managed",
managedProfileId: "qualified",
anthropicAgentId: "agent_test",
agentVersion: "3",
anthropicEnvironmentId: "env_test",
model: "claude-sonnet-5",
maxSessionListCostUsd: 1,
managedAgentsRetentionAcknowledged: true,
env: { ANTHROPIC_API_KEY: "anthropic-config-secret" },
};
describe("paperclip_runner Claude Agent environment probe", () => {
beforeEach(() => {
vi.stubEnv("ANTHROPIC_API_KEY", "");
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it("uses the resolved adapter secret and performs only read-only qualified-resource probes", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
calls.push({ url, init: init ?? {} });
if (url.includes("/versions")) {
return new Response(JSON.stringify({ data: [{ version: 3 }] }), { status: 200 });
}
if (url.includes("/environments/")) {
return new Response(JSON.stringify({
id: "env_test",
config: {
type: "cloud",
networking: {
type: "limited",
allow_mcp_servers: false,
allow_package_managers: false,
allowed_hosts: [],
},
packages: { type: "packages", apt: [], cargo: [], gem: [], go: [], npm: [], pip: [] },
},
}), { status: 200 });
}
return new Response(JSON.stringify({ id: "agent_test" }), { status: 200 });
}));
const result = await findServerAdapter("paperclip_runner")!.testEnvironment!({
companyId: "company-1",
adapterType: "paperclip_runner",
config: CONFIG,
});
expect(result.status).toBe("warn");
expect(result.checks.some((check) => check.code === "claude_managed_profile_qualified")).toBe(true);
expect(calls).toHaveLength(3);
expect(calls.every((call) => (call.init.method ?? "GET") === "GET")).toBe(true);
for (const call of calls) {
expect(call.init.headers).toMatchObject({
"x-api-key": "anthropic-config-secret",
"anthropic-beta": "managed-agents-2026-04-01",
});
}
expect(JSON.stringify(result)).not.toContain("anthropic-config-secret");
});
it("rejects unsafe Environment drift", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.includes("/versions")) return new Response(JSON.stringify({ data: [{ version: 3 }] }));
if (url.includes("/environments/")) {
return new Response(JSON.stringify({
id: "env_test",
config: {
type: "cloud",
networking: { type: "unrestricted" },
packages: { type: "packages" },
},
}));
}
return new Response(JSON.stringify({ id: "agent_test" }));
}));
const result = await findServerAdapter("paperclip_runner")!.testEnvironment!({
companyId: "company-1",
adapterType: "paperclip_runner",
config: CONFIG,
});
expect(result.status).toBe("fail");
expect(result.checks.some((check) => check.code === "claude_managed_probe_failed")).toBe(true);
});
});
+187 -8
View File
@@ -188,6 +188,155 @@ async function testPaperclipRunnerOpenCodeEnvironment(
}
}
async function testPaperclipRunnerClaudeManagedEnvironment(
context: Parameters<typeof codexTestEnvironment>[0],
) {
const testedAt = new Date().toISOString();
const required = [
["managedProfileId", "Managed profile ID"],
["anthropicAgentId", "Anthropic Agent ID"],
["agentVersion", "Agent version"],
["anthropicEnvironmentId", "Anthropic Environment ID"],
["model", "Claude model"],
] as const;
const missing = required.filter(([key]) =>
typeof context.config[key] !== "string" || String(context.config[key]).trim().length === 0
);
if (missing.length > 0 || context.config.managedAgentsRetentionAcknowledged !== true) {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{
code: "claude_managed_profile_incomplete",
level: "error" as const,
message: missing.length > 0
? `Claude Agent profile is missing: ${missing.map(([, label]) => label).join(", ")}.`
: "Claude Agent requires an administrator acknowledgement of beta status and provider retention.",
}],
};
}
const cap = Number(context.config.maxSessionListCostUsd);
if (!Number.isFinite(cap) || cap <= 0) {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{ code: "claude_managed_spend_cap_invalid", level: "error" as const, message: "Claude Agent requires a positive session spend ceiling." }],
};
}
const configuredEnv = context.config.env && typeof context.config.env === "object"
? context.config.env as Record<string, unknown>
: {};
const key = typeof configuredEnv.ANTHROPIC_API_KEY === "string"
? configuredEnv.ANTHROPIC_API_KEY.trim()
: process.env.ANTHROPIC_API_KEY?.trim();
if (!key) {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{
code: "claude_managed_auth_missing",
level: "error" as const,
message: "ANTHROPIC_API_KEY is not available to the Paperclip server. Bind it through the company secret-reference environment configuration.",
}],
};
}
const headers = {
"x-api-key": key,
"anthropic-version": "2023-06-01",
"anthropic-beta": "managed-agents-2026-04-01",
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const [agentResponse, environmentResponse, versionsResponse] = await Promise.all([
fetch(`https://api.anthropic.com/v1/agents/${encodeURIComponent(String(context.config.anthropicAgentId))}?beta=true`, { headers, signal: controller.signal }),
fetch(`https://api.anthropic.com/v1/environments/${encodeURIComponent(String(context.config.anthropicEnvironmentId))}?beta=true`, { headers, signal: controller.signal }),
fetch(`https://api.anthropic.com/v1/agents/${encodeURIComponent(String(context.config.anthropicAgentId))}/versions?beta=true`, { headers, signal: controller.signal }),
]);
if (!agentResponse.ok || !environmentResponse.ok || !versionsResponse.ok) {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{
code: "claude_managed_resource_probe_failed",
level: "error" as const,
message: `Anthropic resource probe failed (Agent HTTP ${agentResponse.status}; Environment HTTP ${environmentResponse.status}; versions HTTP ${versionsResponse.status}).`,
}],
};
}
const agent = await agentResponse.json() as Record<string, unknown>;
const environment = await environmentResponse.json() as Record<string, unknown>;
const versions = await versionsResponse.json() as Record<string, unknown>;
if (agent.id !== context.config.anthropicAgentId || environment.id !== context.config.anthropicEnvironmentId) {
throw new Error("Anthropic returned resource identities that do not match the configured profile");
}
const versionRows = Array.isArray(versions.data) ? versions.data : [];
const pinnedVersion = String(context.config.agentVersion);
if (!versionRows.some((entry) => {
if (!entry || typeof entry !== "object") return false;
const value = (entry as Record<string, unknown>).version;
return String(value) === pinnedVersion;
})) {
throw new Error("The configured Anthropic Agent version does not exist");
}
const environmentConfig = environment.config && typeof environment.config === "object"
? environment.config as Record<string, unknown>
: {};
const networking = environmentConfig.networking && typeof environmentConfig.networking === "object"
? environmentConfig.networking as Record<string, unknown>
: {};
const packages = environmentConfig.packages && typeof environmentConfig.packages === "object"
? environmentConfig.packages as Record<string, unknown>
: {};
const packageEntries = Object.entries(packages)
.filter(([key]) => key !== "type")
.flatMap(([, value]) => Array.isArray(value) ? value : []);
if (
environmentConfig.type !== "cloud"
|| networking.type !== "limited"
|| networking.allow_mcp_servers === true
|| networking.allow_package_managers === true
|| (Array.isArray(networking.allowed_hosts) && networking.allowed_hosts.length > 0)
|| packageEntries.length > 0
) {
throw new Error("Anthropic Environment drifted from Paperclip's no-network, no-package profile");
}
return {
adapterType: "paperclip_runner",
status: "warn" as const,
testedAt,
checks: [{
code: "claude_managed_profile_qualified",
level: "info" as const,
message: `Verified Claude Agent ${String(context.config.anthropicAgentId)} and Environment ${String(context.config.anthropicEnvironmentId)} without creating a session.`,
}, {
code: "claude_managed_retention_notice",
level: "warn" as const,
message: "Managed Agents is a stateful beta service and is not eligible for ZDR or HIPAA modes.",
}],
};
} catch (error) {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{
code: "claude_managed_probe_failed",
level: "error" as const,
message: error instanceof Error && error.name === "AbortError"
? "Anthropic resource probe timed out."
: "Anthropic resource probe failed before the profile could be verified.",
}],
};
} finally {
clearTimeout(timeout);
}
}
function compareSemver(left: string, right: string): number {
const a = left.split(".").map(Number);
const b = right.split(".").map(Number);
@@ -409,8 +558,12 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
};
},
async testEnvironment(context) {
const provider = context.config.provider === "opencode" ? "opencode" : "codex";
const result = provider === "opencode"
const provider = context.config.provider === "opencode"
? "opencode"
: context.config.provider === "claude_managed" ? "claude_managed" : "codex";
const result = provider === "claude_managed"
? await testPaperclipRunnerClaudeManagedEnvironment(context)
: provider === "opencode"
? await testPaperclipRunnerOpenCodeEnvironment(context)
: await codexTestEnvironment(context);
return { ...result, adapterType: "paperclip_runner" };
@@ -421,6 +574,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
models: [
...codexModels,
{ id: "openrouter/deepseek/deepseek-v4-flash-0731", label: "OpenRouter · DeepSeek V4 Flash 0731" },
{ id: "claude-sonnet-5", label: "Claude Sonnet 5" },
],
modelProfiles: codexModelProfiles,
listModels: listCodexModels,
@@ -429,18 +583,24 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
getRuntimeCommandSpec: (config) => config.provider === "opencode"
getRuntimeCommandSpec: (config) => config.provider === "claude_managed"
? { command: "paperclip-runnerd", detectCommand: null, installCommand: null }
: config.provider === "opencode"
? buildNpmRuntimeCommandSpec(config, "opencode", "opencode-ai@1.18.17")
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex or OpenCode through the native Paperclip runner and authenticated PRP control-plane connection. OpenCode requires a provider/model identifier and runtime OPENROUTER_API_KEY.\n`,
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, or a remote Claude Agent through the native Paperclip runner and authenticated PRP control-plane connection. Claude Agent credentials remain in runnerd; Anthropic never receives a Paperclip credential or endpoint.\n`,
getConfigSchema: () => ({
fields: [{
key: "provider",
label: "Provider",
type: "select",
default: "codex",
options: [{ value: "codex", label: "Codex" }, { value: "opencode", label: "OpenCode 1.18.17" }],
hint: "Select the native provider. OpenCode runs through its authenticated loopback server.",
options: [
{ value: "codex", label: "Codex" },
{ value: "opencode", label: "OpenCode 1.18.17" },
{ value: "claude_managed", label: "Claude Agent" },
],
hint: "Select a local harness or an Anthropic cloud-managed Claude Agent.",
}, {
key: "lifecycleMode",
label: "Runner lifecycle",
@@ -459,17 +619,36 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
hint: "Warm sessions suspend resumably after this much inactivity.",
}, {
key: "model",
label: "OpenCode model",
label: "Model",
type: "text",
default: "openrouter/deepseek/deepseek-v4-flash-0731",
placeholder: "openrouter/deepseek/deepseek-v4-flash-0731",
hint: "Required when provider is OpenCode; use provider/model form.",
hint: "OpenCode uses provider/model form. Claude Agent defaults to claude-sonnet-5.",
}, {
key: "command",
label: "OpenCode command",
type: "text",
default: "opencode",
hint: "OpenCode executable. Version 1.18.17 is qualified.",
}, {
key: "managedProfileId", label: "Managed Agent profile", type: "text", required: true,
hint: "Company-scoped profile identifier. Required for Claude Agent.",
meta: { provider: "claude_managed" },
}, {
key: "anthropicAgentId", label: "Anthropic Agent ID", type: "text", required: true,
meta: { provider: "claude_managed" },
}, {
key: "agentVersion", label: "Pinned Agent version", type: "text", required: true,
meta: { provider: "claude_managed" },
}, {
key: "anthropicEnvironmentId", label: "Anthropic Environment ID", type: "text", required: true,
meta: { provider: "claude_managed" },
}, {
key: "maxSessionListCostUsd", label: "Session spend ceiling (USD)", type: "number", default: 1,
hint: "Required hard ceiling for a managed session.", meta: { provider: "claude_managed" },
}, {
key: "managedAgentsRetentionAcknowledged", label: "Acknowledge beta retention", type: "toggle", default: false,
hint: "Managed Agents is stateful and is not eligible for ZDR or HIPAA modes.", meta: { provider: "claude_managed" },
}],
}),
loginCapability: codexLoginCapability,
+2
View File
@@ -82,6 +82,7 @@ import { accessRoutes } from "./routes/access.js";
import { pluginRoutes } from "./routes/plugins.js";
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "./routes/tool-gateway.js";
import { adapterRoutes } from "./routes/adapters.js";
import { managedAgentProfileRoutes } from "./routes/managed-agent-profiles.js";
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
import { readBrandedStaticIndexHtml } from "./static-index-html.js";
import { applyUiBranding } from "./ui-branding.js";
@@ -522,6 +523,7 @@ export async function createApp(
api.use(boardChatRoutes(db, { deploymentMode: opts.deploymentMode }));
api.use(approvalRoutes(db, { pluginWorkerManager: workerManager }));
api.use(secretRoutes(db));
api.use(managedAgentProfileRoutes(db));
const trustedLocalStdioRuntimeHost =
process.env.PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST
?? process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST
+1
View File
@@ -39,3 +39,4 @@ export { llmRoutes } from "./llms.js";
export { accessRoutes } from "./access.js";
export { instanceSettingsRoutes } from "./instance-settings.js";
export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js";
export { managedAgentProfileRoutes } from "./managed-agent-profiles.js";
@@ -0,0 +1,69 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { managedAgentProfileService, type ManagedAgentProfileInput } from "../services/managed-agent-profiles.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { logActivity } from "../services/activity-log.js";
import { unprocessable } from "../errors.js";
function profileInput(value: unknown): ManagedAgentProfileInput {
if (!value || typeof value !== "object") throw unprocessable("Managed Agent profile body is required");
const body = value as Record<string, unknown>;
return {
profileKey: String(body.profileKey ?? ""),
displayName: String(body.displayName ?? ""),
anthropicAgentId: String(body.anthropicAgentId ?? ""),
agentVersion: String(body.agentVersion ?? ""),
environmentId: String(body.environmentId ?? ""),
defaultModel: String(body.defaultModel ?? "claude-sonnet-5"),
defaultMaxListCostUsd: Number(body.defaultMaxListCostUsd ?? 1),
apiKeySecretId: String(body.apiKeySecretId ?? ""),
enabled: body.enabled === true,
retentionAcknowledged: body.retentionAcknowledged === true,
qualification: body.qualification && typeof body.qualification === "object"
? body.qualification as Record<string, unknown>
: {},
};
}
export function managedAgentProfileRoutes(db: Db) {
const router = Router();
const profiles = managedAgentProfileService(db);
router.get("/companies/:companyId/managed-agent-profiles", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await profiles.list(companyId));
});
router.post("/companies/:companyId/managed-agent-profiles", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const profile = await profiles.upsert(companyId, profileInput(req.body));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "managed_agent_profile.qualified",
entityType: "managed_agent_profile",
entityId: profile.id,
details: {
profileKey: profile.profileKey,
service: profile.service,
agentVersion: profile.agentVersion,
environmentId: profile.environmentId,
model: profile.defaultModel,
enabled: profile.enabled,
retentionAcknowledged: profile.retentionAcknowledged,
},
});
res.status(201).json(profile);
});
return router;
}
+72 -16
View File
@@ -85,6 +85,7 @@ export { scrubGitCredentialText };
import { publishLiveEvent } from "./live-events.js";
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
import {
buildNativeProviderEnvironment,
buildNativeExecutionInput,
cancelNativeSession,
dispatchNativeSessionResumptions,
@@ -303,6 +304,7 @@ import { evaluateCodexCredentialReadiness } from "@paperclipai/adapter-codex-loc
import { environmentService } from "./environments.js";
import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js";
import { environmentRuntimeService } from "./environment-runtime.js";
import { managedAgentProfileService } from "./managed-agent-profiles.js";
import { skillVersionSelectionMap } from "./runtime-skill-selections.js";
import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js";
@@ -14062,6 +14064,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
activeRunExecutions.add(run.id);
let runScratch: HeartbeatRunScratch | null = null;
let nativeSessionResumeScheduled = false;
try {
const agent = await getAgent(run.agentId);
@@ -15923,6 +15926,32 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
agentId: agent.id,
interactionIds: interactionId ? [interactionId] : [],
});
const managedProfile = nativeRuntimeResolution.profile.backend === "claude_managed_agents_api"
? await managedAgentProfileService(db).requireQualified(
agent.companyId,
readNonEmptyString(parseObject(runtimeConfig).managedProfileId) ?? "",
)
: null;
if (managedProfile) {
const rawApiKeyBinding = parseObject(parseObject(agent.adapterConfig).env).ANTHROPIC_API_KEY;
const boundSecretId = typeof rawApiKeyBinding === "object" && rawApiKeyBinding !== null
? readNonEmptyString((rawApiKeyBinding as Record<string, unknown>).secretId)
: null;
if (boundSecretId !== managedProfile.apiKeySecretId) {
throw new ConfigurationIncompleteFailure(
"configuration incomplete: Claude Agent profile API key is not bound at env.ANTHROPIC_API_KEY",
{
configurationIncomplete: {
reason: "managed_agent_profile_secret_binding_mismatch",
companyId: agent.companyId,
agentId: agent.id,
profileId: managedProfile.id,
requiredEnvKeys: ["ANTHROPIC_API_KEY"],
},
},
);
}
}
nativeExecution = buildNativeExecutionInput({
companyId: agent.companyId,
runId: run.id,
@@ -15941,11 +15970,28 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
branchName: executionWorkspace.branchName,
},
normalizedSessionId: nativeSessionId,
provider: nativeRuntimeResolution.profile.backend === "opencode_server" ? "opencode" : "codex",
model: typeof parseObject(agent.adapterConfig).model === "string"
? String(parseObject(agent.adapterConfig).model)
: null,
lifecyclePolicy: parseObject(agent.adapterConfig).lifecycleMode === "warm"
provider: nativeRuntimeResolution.profile.backend === "opencode_server"
? "opencode"
: nativeRuntimeResolution.profile.backend === "claude_managed_agents_api"
? "claude_managed"
: "codex",
model: typeof parseObject(runtimeConfig).model === "string"
? String(parseObject(runtimeConfig).model)
: managedProfile?.defaultModel ?? null,
...(nativeRuntimeResolution.profile.backend === "claude_managed_agents_api" ? {
managedProfile: {
profileId: managedProfile!.id,
anthropicAgentId: managedProfile!.anthropicAgentId,
agentVersion: managedProfile!.agentVersion,
environmentId: managedProfile!.environmentId,
betaVersion: "managed-agents-2026-04-01" as const,
},
maxSessionListCostUsd: Number(
parseObject(runtimeConfig).maxSessionListCostUsd
?? managedProfile!.defaultMaxListCostCents / 100,
),
} : {}),
lifecyclePolicy: parseObject(runtimeConfig).lifecycleMode === "warm"
? {
mode: "warm",
idleTimeoutMs: Number.isSafeInteger(parseObject(agent.adapterConfig).idleTimeoutMs)
@@ -16223,6 +16269,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
backend: options.nativeSessionBackendFactory?.(nativeExecution),
useRunnerd: agent.adapterType === "paperclip_runner",
onLog,
// Bootstrap the provider with executable/home discovery while
// keeping the agent's configured provider values authoritative.
runnerEnvironment: buildNativeProviderEnvironment(adapterEnv),
onSpawn: async (meta) => {
await persistRunProcessMetadata(run.id, meta);
},
@@ -16306,7 +16355,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
.limit(1)
.then((rows) => rows[0]?.phase === "retryable_failure" && rows[0]?.resultId === null)
: false;
if (nativeResumeScheduled) throw new NativeSessionResumeScheduledError(adapterErr);
if (nativeResumeScheduled) {
nativeSessionResumeScheduled = true;
throw new NativeSessionResumeScheduledError(adapterErr);
}
// Adapter (or its restore finally) threw — or the finalize record
// write itself threw. Either way the workspace may be in a partial
// state. Best-effort record finalize=failed so the dependent readiness
@@ -17058,7 +17110,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
// terminal". When the teardown reaches this point with the run still
// running or queued, force a terminal status before the lease is
// released, so the UI never shows a finished task as "Live".
if (latestRun) {
if (latestRun && !nativeSessionResumeScheduled) {
latestRun = await terminalizeRunOnLeaseRelease(latestRun).catch((terminalizeErr) => {
logger.error(
{ err: terminalizeErr, runId: run.id },
@@ -17067,14 +17119,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return latestRun;
});
}
await releaseEnvironmentLeasesForRun({
runId: run.id,
companyId: run.companyId,
agentId: run.agentId,
status: latestRun?.status,
failureReason: latestRun?.error ?? undefined,
});
await releaseRuntimeServicesForRun(run.id).catch(() => undefined);
if (!nativeSessionResumeScheduled) {
await releaseEnvironmentLeasesForRun({
runId: run.id,
companyId: run.companyId,
agentId: run.agentId,
status: latestRun?.status,
failureReason: latestRun?.error ?? undefined,
});
await releaseRuntimeServicesForRun(run.id).catch(() => undefined);
}
if (runScratch && latestRun && isHeartbeatRunTerminalStatus(latestRun.status)) {
const scratchForCleanup = runScratch;
let scratchCleanup: Awaited<ReturnType<typeof cleanupHeartbeatRunScratch>> | null = null;
@@ -17128,7 +17182,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
}
activeRunExecutions.delete(run.id);
await startNextQueuedRunForAgent(run.agentId);
if (!nativeSessionResumeScheduled) {
await startNextQueuedRunForAgent(run.agentId);
}
}
}
+5
View File
@@ -192,3 +192,8 @@ export {
} from "./codex-auth-reconciliation.js";
export { reconcilePersistedRuntimeServicesOnStartup, restartDesiredRuntimeServicesOnStartup } from "./workspace-runtime.js";
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";
export {
managedAgentProfileService,
CLAUDE_MANAGED_BETA_VERSION,
type ManagedAgentProfileInput,
} from "./managed-agent-profiles.js";
@@ -0,0 +1,109 @@
import { and, asc, eq, ne } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companySecrets, managedAgentProfiles } from "@paperclipai/db";
import { conflict, notFound, unprocessable } from "../errors.js";
export const CLAUDE_MANAGED_BETA_VERSION = "managed-agents-2026-04-01" as const;
export interface ManagedAgentProfileInput {
profileKey: string;
displayName: string;
anthropicAgentId: string;
agentVersion: string;
environmentId: string;
defaultModel: string;
defaultMaxListCostUsd: number;
apiKeySecretId: string;
enabled: boolean;
retentionAcknowledged: boolean;
qualification?: Record<string, unknown>;
}
function required(value: string, label: string): string {
const normalized = value.trim();
if (!normalized) throw unprocessable(`${label} is required`);
return normalized;
}
function toCents(value: number): number {
const cents = Math.round(value * 100);
if (!Number.isSafeInteger(cents) || cents <= 0) {
throw unprocessable("Managed Agent default spend ceiling must be positive");
}
return cents;
}
export function managedAgentProfileService(db: Db) {
async function list(companyId: string) {
return db.select().from(managedAgentProfiles)
.where(eq(managedAgentProfiles.companyId, companyId))
.orderBy(asc(managedAgentProfiles.displayName));
}
async function get(companyId: string, profileIdOrKey: string) {
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(profileIdOrKey);
const rows = await db.select().from(managedAgentProfiles).where(and(
eq(managedAgentProfiles.companyId, companyId),
isUuid
? eq(managedAgentProfiles.id, profileIdOrKey)
: eq(managedAgentProfiles.profileKey, profileIdOrKey),
)).limit(1);
return rows[0] ?? null;
}
async function requireQualified(companyId: string, profileIdOrKey: string) {
const profile = await get(companyId, profileIdOrKey);
if (!profile) throw notFound("Managed Agent profile not found");
if (!profile.enabled || !profile.retentionAcknowledged || !profile.qualifiedAt) {
throw conflict("Managed Agent profile is not enabled and qualified");
}
return profile;
}
async function upsert(companyId: string, input: ManagedAgentProfileInput) {
const secret = await db.select({ id: companySecrets.id })
.from(companySecrets)
.where(and(
eq(companySecrets.companyId, companyId),
eq(companySecrets.id, input.apiKeySecretId),
eq(companySecrets.scope, "company"),
ne(companySecrets.status, "deleted"),
))
.limit(1);
if (!secret[0]) throw unprocessable("Managed Agent API-key secret reference is invalid");
if (input.enabled && !input.retentionAcknowledged) {
throw unprocessable("Enabling Managed Agents requires the retention acknowledgement");
}
const profileKey = required(input.profileKey, "Profile key");
const values = {
companyId,
profileKey,
displayName: required(input.displayName, "Display name"),
service: "anthropic_managed_agents",
anthropicAgentId: required(input.anthropicAgentId, "Anthropic Agent ID"),
agentVersion: required(input.agentVersion, "Agent version"),
environmentId: required(input.environmentId, "Anthropic Environment ID"),
betaVersion: CLAUDE_MANAGED_BETA_VERSION,
defaultModel: required(input.defaultModel, "Default model"),
defaultMaxListCostCents: toCents(input.defaultMaxListCostUsd),
apiKeySecretId: input.apiKeySecretId,
enabled: input.enabled,
retentionAcknowledged: input.retentionAcknowledged,
qualification: input.qualification ?? {},
qualifiedAt: input.enabled ? new Date() : null,
updatedAt: new Date(),
} as const;
const [row] = await db.insert(managedAgentProfiles)
.values(values)
.onConflictDoUpdate({
target: [managedAgentProfiles.companyId, managedAgentProfiles.profileKey],
set: values,
})
.returning();
return row!;
}
return { list, get, requireQualified, upsert };
}
@@ -26,8 +26,16 @@ export function buildNativeExecutionInput(input: {
branchName: string | null;
};
normalizedSessionId: string | null;
provider?: "codex" | "opencode";
provider?: "codex" | "opencode" | "claude_managed";
model?: string | null;
managedProfile?: {
profileId: string;
anthropicAgentId: string;
agentVersion: string;
environmentId: string;
betaVersion: "managed-agents-2026-04-01";
};
maxSessionListCostUsd?: number;
lifecyclePolicy?: NativeExecutionInputV1["session"]["lifecyclePolicy"];
interactionResponses?: NativeInteractionResponseEnvelope[];
completionContract: {
@@ -64,11 +72,20 @@ export function buildNativeExecutionInput(input: {
},
session: {
normalizedSessionId: input.normalizedSessionId,
driverKind: input.provider === "opencode" ? "opencode_server" : "codex_app_server",
driverKind: input.provider === "opencode"
? "opencode_server"
: input.provider === "claude_managed"
? "claude_managed_agents_api"
: "codex_app_server",
protocolVersion: 1,
lifecyclePolicy: input.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
},
provider: {
provider: input.provider === "claude_managed" ? {
kind: "claude_managed",
model: input.model,
managedProfile: input.managedProfile,
maxSessionListCostUsd: input.maxSessionListCostUsd,
} : {
kind: input.provider ?? "codex",
model: input.model ?? null,
},
@@ -28,12 +28,43 @@ vi.mock("@paperclipai/paperclip-runner", () => ({
}));
import {
buildNativeProviderEnvironment,
cancelNativeSession,
executePaperclipNativeSession,
nativeSessionFailureDisposition,
nativeSessionFailureSourceCode,
providerPlanMarkdown,
} from "./native-session-executor.js";
describe("native provider bootstrap environment", () => {
it("inherits the host executable and credential-home context", () => {
expect(buildNativeProviderEnvironment({}, {
PATH: "/opt/homebrew/bin:/usr/bin",
HOME: "/Users/runner",
CODEX_HOME: "/Users/runner/.codex",
PAPERCLIP_INTERNAL_SECRET: "must-not-leak",
})).toEqual({
PATH: "/opt/homebrew/bin:/usr/bin",
HOME: "/Users/runner",
CODEX_HOME: "/Users/runner/.codex",
});
});
it("lets explicitly configured agent env override host defaults", () => {
expect(buildNativeProviderEnvironment({
PATH: "/agent/bin",
OPENAI_API_KEY: "configured-provider-key",
}, {
PATH: "/host/bin",
HOME: "/Users/runner",
})).toEqual({
PATH: "/agent/bin",
HOME: "/Users/runner",
OPENAI_API_KEY: "configured-provider-key",
});
});
});
const execution = {
provider: { kind: "codex", model: null },
binding: {
@@ -45,6 +76,30 @@ const execution = {
completionContract: { id: "contract", sha256: "sha" },
} as NativeExecutionInputV1;
describe("provider plan synchronization", () => {
it("renders a bounded Markdown checklist without embedding provenance", () => {
const markdown = providerPlanMarkdown({
explanation: "Release safely",
steps: [
{ body: "Prepare", status: "completed" },
{ body: "Deploy", status: "in_progress" },
{ body: "Verify", status: "blocked" },
],
runId: "must-not-appear",
providerThreadId: "native-secret",
});
expect(markdown).toBe([
"Release safely",
"",
"- [x] Prepare",
"- [ ] Deploy _(in progress)_",
"- [ ] Verify _(blocked)_",
].join("\n"));
expect(markdown).not.toContain("must-not-appear");
expect(markdown).not.toContain("native-secret");
});
});
function leaseDb(boundExecution: NativeExecutionInputV1 = execution): Db {
const coordinator = {
runId: boundExecution.binding.runId,
@@ -16,8 +16,8 @@ import {
executeNativeSession,
} from "../../vendor/paperclip-runner/index.js";
import type { Db } from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm";
import { heartbeatRuns, issues, nativeRunFinalizations } from "@paperclipai/db";
import { and, desc, eq, sql } from "drizzle-orm";
import { documentRevisions, heartbeatRuns, issueDocuments, issues, nativeRunFinalizations } from "@paperclipai/db";
import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js";
@@ -25,11 +25,13 @@ import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { persistActivity, publishActivity } from "../activity-log.js";
import { commitNativeStatusDecision } from "./status-decision-committer.js";
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
import { documentService } from "../documents.js";
import {
NATIVE_STATUS_ARBITER_POLICY_VERSION,
type NativeAuthoritativeIssueStatus,
type NativeStatusDecision,
} from "./status-arbiter.js";
import { HttpError } from "../../errors.js";
type ActiveNativeSession = {
session: NativeSession;
@@ -48,6 +50,109 @@ type WarmNativeSession = {
const warmNativeSessions = new Map<string, WarmNativeSession>();
const NATIVE_PROVIDER_HOST_ENV_KEYS = [
"PATH",
"HOME",
"USER",
"LOGNAME",
"SHELL",
"TMPDIR",
"TEMP",
"TMP",
"CODEX_HOME",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
"SystemRoot",
"PATHEXT",
] as const;
/**
* Provider bootstrap needs a small amount of host process context even when
* the agent has no configured env. In particular, an empty environment makes
* a bare `codex` command unresolvable. Agent-configured values remain
* authoritative and may intentionally override the host defaults.
*/
export function buildNativeProviderEnvironment(
configured: NodeJS.ProcessEnv,
host: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const inherited = Object.fromEntries(
NATIVE_PROVIDER_HOST_ENV_KEYS.flatMap((key) => {
const value = host[key];
return typeof value === "string" && value.length > 0 ? [[key, value]] : [];
}),
);
return { ...inherited, ...configured };
}
type PlanSynchronization = {
eventId: string;
planId: string;
providerRevision: number;
status: "synchronized" | "already_synchronized" | "conflict" | "invalid";
documentRevision: number | null;
currentRevisionId: string | null;
};
export function providerPlanMarkdown(payload: Record<string, unknown>): string {
const explanation = typeof payload.explanation === "string" ? payload.explanation.trim() : "";
const steps = Array.isArray(payload.steps) ? payload.steps : [];
const lines = steps.slice(0, 256).flatMap((value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
const step = value as Record<string, unknown>;
const body = typeof step.body === "string" ? step.body.trim().slice(0, 4_000) : "";
if (!body) return [];
const status = step.status === "completed" ? "x" : " ";
const suffix = step.status === "blocked" ? " _(blocked)_" : step.status === "in_progress" ? " _(in progress)_" : "";
return [`- [${status}] ${body}${suffix}`];
});
return [explanation, lines.join("\n")].filter(Boolean).join("\n\n").slice(0, 256_000);
}
async function synchronizeCompletedProviderPlan(input: {
db: Db;
execution: NativeExecutionInputV1;
event: { sourceEventId: string; turnId?: string; eventType: string; payload: Record<string, unknown> };
}): Promise<PlanSynchronization | null> {
if (input.event.eventType !== "plan.updated" || input.event.payload.complete !== true) return null;
const planId = typeof input.event.payload.planId === "string" ? input.event.payload.planId : "";
const providerRevision = Number.isSafeInteger(input.event.payload.revision) ? Number(input.event.payload.revision) : 0;
const body = providerPlanMarkdown(input.event.payload);
if (!planId || providerRevision < 1 || !body) {
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "invalid", documentRevision: null, currentRevisionId: null };
}
const provenance = `runner-plan-sync:v1 run=${input.execution.binding.runId} turn=${input.event.turnId ?? "unknown"} provider=${input.execution.provider.kind} plan=${planId} revision=${providerRevision}`;
const existingRevision = await input.db.select({ revisionNumber: documentRevisions.revisionNumber, id: documentRevisions.id })
.from(documentRevisions)
.innerJoin(issueDocuments, eq(issueDocuments.documentId, documentRevisions.documentId))
.where(and(eq(issueDocuments.issueId, input.execution.binding.issueId), eq(issueDocuments.key, "plan"), eq(documentRevisions.changeSummary, provenance)))
.orderBy(desc(documentRevisions.revisionNumber)).limit(1).then((rows) => rows[0] ?? null);
if (existingRevision) {
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "already_synchronized", documentRevision: existingRevision.revisionNumber, currentRevisionId: existingRevision.id };
}
const documents = documentService(input.db);
const current = await documents.getIssueDocumentByKey(input.execution.binding.issueId, "plan");
try {
const write = await documents.upsertIssueDocument({
issueId: input.execution.binding.issueId,
key: "plan",
title: "Plan",
format: "markdown",
body,
baseRevisionId: current?.latestRevisionId ?? null,
changeSummary: provenance,
createdByAgentId: input.execution.binding.agentId,
createdByRunId: input.execution.binding.runId,
});
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "synchronized", documentRevision: write.document.latestRevisionNumber, currentRevisionId: write.document.latestRevisionId };
} catch (error) {
if (!(error instanceof HttpError) || error.status !== 409) throw error;
const latest = await documents.getIssueDocumentByKey(input.execution.binding.issueId, "plan");
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "conflict", documentRevision: latest?.latestRevisionNumber ?? null, currentRevisionId: latest?.latestRevisionId ?? null };
}
}
class SessionToolAuthorityRouter {
#authority: PaperclipRunnerToolAuthority;
@@ -75,12 +180,17 @@ function nativeSessionKey(execution: NativeExecutionInputV1): string {
}
function nativeSessionConfigDigest(execution: NativeExecutionInputV1): string {
const executionLocation = execution.provider.kind === "claude_managed"
? { executionKind: "remote_service", workspace: null }
: {
executionKind: "local_process",
workspaceId: execution.binding.executionWorkspaceId,
cwd: execution.workspace.cwd,
};
return `sha256:${createHash("sha256").update(JSON.stringify({
companyId: execution.binding.companyId,
issueId: execution.binding.issueId,
agentId: execution.binding.agentId,
workspaceId: execution.binding.executionWorkspaceId,
cwd: execution.workspace.cwd,
normalizedSessionId: nativeSessionKey(execution),
executionLocation,
provider: execution.provider,
driverKind: execution.session.driverKind,
lifecyclePolicy: execution.session.lifecyclePolicy,
@@ -397,6 +507,8 @@ export async function executePaperclipNativeSession(input: {
backend?: NativeSessionBackend;
useRunnerd?: boolean;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
/** Resolved adapter env; the runner transport applies a provider allowlist before spawn. */
runnerEnvironment?: NodeJS.ProcessEnv;
}): Promise<AdapterExecutionResult> {
const leaseOwner = input.leaseOwner ?? `${input.runnerInstanceId}:${randomUUID()}`;
const leaseNow = new Date();
@@ -434,6 +546,7 @@ export async function executePaperclipNativeSession(input: {
return coordinator.attempt + 1;
});
const controlPlaneInstanceId = `${input.runnerInstanceId}:control`;
const planSynchronizations: PlanSynchronization[] = [];
const controlPlane = new PaperclipControlPlanePort(input.db, {
companyId: input.execution.binding.companyId,
issueId: input.execution.binding.issueId,
@@ -444,9 +557,18 @@ export async function executePaperclipNativeSession(input: {
sourceInstanceId: input.runnerInstanceId,
controlPlaneSourceInstanceId: controlPlaneInstanceId,
}, {
onCommittedEvent: input.onLog
? async (event) => input.onLog!("stdout", `${JSON.stringify({ type: "paperclip.prp.event", event })}\n`)
: undefined,
onCommittedEvent: async (event) => {
if (input.onLog) await input.onLog("stdout", `${JSON.stringify({ type: "paperclip.prp.event", event })}\n`);
const synchronization = await synchronizeCompletedProviderPlan({
db: input.db,
execution: input.execution,
event: event as { sourceEventId: string; turnId?: string; eventType: string; payload: Record<string, unknown> },
});
if (synchronization) {
planSynchronizations.push(synchronization);
if (input.onLog) await input.onLog("stdout", `${JSON.stringify({ type: "paperclip.plan.synchronization", synchronization })}\n`);
}
},
});
let native: Awaited<ReturnType<typeof executeNativeSession>>;
const lifecyclePolicy = input.execution.session?.lifecyclePolicy
@@ -479,7 +601,7 @@ export async function executePaperclipNativeSession(input: {
?? createNativeSessionBackend(input.execution, {
runnerInstanceId: input.runnerInstanceId,
onSpawn: input.onSpawn,
environment: process.env,
environment: input.runnerEnvironment ?? process.env,
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
}),
controlPlane,
@@ -617,16 +739,19 @@ export async function executePaperclipNativeSession(input: {
resultJson: {
nativeResult: native.result as unknown as Record<string, unknown>,
nativeTerminal: native.terminal as unknown as Record<string, unknown>,
planSynchronizations,
},
summary: native.result.summary,
sessionId: native.normalizedSessionId,
sessionDisplayId: native.providerSessionId ?? native.normalizedSessionId,
provider: input.execution.provider.kind === "opencode"
provider: input.execution.provider.kind === "claude_managed"
? "anthropic"
: input.execution.provider.kind === "opencode"
? input.execution.provider.model?.split("/", 1)[0] ?? "opencode"
: "openai",
model: input.execution.provider.model,
usage: normalizeNativeUsage(native.usage),
costUsd: numericUsageField(native.usage, ["costUsd", "cost"]),
costUsd: numericUsageField(native.usage, ["providerCostUsd", "costUsd", "cost"]),
usageBasis: "per_run",
nativeFinalization: finalization,
};
@@ -661,6 +786,7 @@ function createRunnerdBackend(input: {
execution: NativeExecutionInputV1;
runnerInstanceId: string;
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
runnerEnvironment?: NodeJS.ProcessEnv;
}): NativeSessionBackend {
const authority = new PaperclipRunnerToolAuthority(input.db, {
companyId: input.execution.binding.companyId,
@@ -683,11 +809,19 @@ function createRunnerdBackend(input: {
onSpawn: input.onSpawn,
dynamicTools: authorityRouter.definitions(),
dynamicToolHandler: (call) => authorityRouter.execute(call),
environment: process.env,
environment: input.runnerEnvironment ?? process.env,
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
codexTransportFactory: () => createRunnerdCodexTransport({
provider: input.execution.provider.kind,
...(input.execution.provider.kind === "claude_managed" ? {
managedProfile: {
...input.execution.provider.managedProfile,
maxSessionListCostUsd: input.execution.provider.maxSessionListCostUsd,
model: input.execution.provider.model,
},
} : {}),
stateDirectory: root,
environment: process.env,
environment: input.runnerEnvironment ?? process.env,
lifecyclePolicy: input.execution.session.lifecyclePolicy,
resumeDynamicTools: authorityRouter.definitions(),
prpIdentity: {
@@ -35,7 +35,7 @@ describe("resolveNativeRuntimeMode", () => {
runtimeConfig: {},
adapterConfig: { provider: "claude" },
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
})).toThrow(/provider must be codex or opencode/);
})).toThrow(/provider must be codex, opencode, or claude_managed/);
});
it("selects OpenCode only with a provider/model value", () => {
@@ -17,7 +17,11 @@ export type NativeRuntimeResolution =
kind: "native";
resolverVersion: typeof NATIVE_RUNTIME_RESOLVER_VERSION;
reason: "eligible_opt_in";
profile: { mode: "native"; backend: "codex_app_server" | "opencode_server"; protocolVersion: 1 };
profile: {
mode: "native";
backend: "codex_app_server" | "opencode_server" | "claude_managed_agents_api";
protocolVersion: 1;
};
authorityDecision: NativeStatusDecision;
};
@@ -47,8 +51,8 @@ export function resolveNativeRuntimeMode(input: {
const nativeRunner = record(record(input.runtimeConfig).nativeRunner);
const runnerAdapterSelected = input.agent.adapterType === "paperclip_runner";
const runnerProvider = record(input.adapterConfig).provider ?? "codex";
if (runnerAdapterSelected && runnerProvider !== "codex" && runnerProvider !== "opencode") {
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex or opencode");
if (runnerAdapterSelected && !["codex", "opencode", "claude_managed"].includes(String(runnerProvider))) {
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex, opencode, or claude_managed");
}
if (runnerAdapterSelected && runnerProvider === "opencode") {
const model = record(input.adapterConfig).model;
@@ -56,6 +60,21 @@ export function resolveNativeRuntimeMode(input: {
throw new NativeRuntimeEligibilityError("paperclip_runner OpenCode provider requires model in provider/model form");
}
}
if (runnerAdapterSelected && runnerProvider === "claude_managed") {
const config = record(input.adapterConfig);
for (const key of ["managedProfileId", "anthropicAgentId", "agentVersion", "anthropicEnvironmentId", "model"]) {
if (typeof config[key] !== "string" || String(config[key]).trim().length === 0) {
throw new NativeRuntimeEligibilityError(`paperclip_runner Claude Agent provider requires ${key}`);
}
}
if (config.managedAgentsRetentionAcknowledged !== true) {
throw new NativeRuntimeEligibilityError("paperclip_runner Claude Agent provider requires retention acknowledgement");
}
const cap = Number(config.maxSessionListCostUsd);
if (!Number.isFinite(cap) || cap <= 0) {
throw new NativeRuntimeEligibilityError("paperclip_runner Claude Agent provider requires a positive spend ceiling");
}
}
const mode = runnerAdapterSelected ? "native" : nativeRunner.mode;
if (!runnerAdapterSelected && !input.enabled) {
const compatibility = resolveNativeCompatibilityStatus({
@@ -90,7 +109,7 @@ export function resolveNativeRuntimeMode(input: {
throw new NativeRuntimeEligibilityError("run must be bound to a standard issue");
}
if (
(!input.workspaceId && !runnerAdapterSelected)
(!input.workspaceId && !runnerAdapterSelected && runnerProvider !== "claude_managed")
|| input.target?.kind && input.target.kind !== "local"
) {
throw new NativeRuntimeEligibilityError("a realized local workspace is required");
@@ -109,7 +128,11 @@ export function resolveNativeRuntimeMode(input: {
reason: "eligible_opt_in",
profile: {
mode: "native",
backend: runnerProvider === "opencode" ? "opencode_server" : "codex_app_server",
backend: runnerProvider === "opencode"
? "opencode_server"
: runnerProvider === "claude_managed"
? "claude_managed_agents_api"
: "codex_app_server",
protocolVersion: 1,
},
authorityDecision: rollout,
@@ -144,7 +167,11 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
reason: "eligible_opt_in",
profile: {
mode: "native",
backend: input.persisted.driverKind === "opencode_server" ? "opencode_server" : "codex_app_server",
backend: input.persisted.driverKind === "opencode_server"
? "opencode_server"
: input.persisted.driverKind === "claude_managed_agents_api"
? "claude_managed_agents_api"
: "codex_app_server",
protocolVersion: 1,
},
authorityDecision: resolveNativeMigrationStatus({
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { shouldRouteRecoveryToOriginalAgent } from "./service.js";
describe("native runner recovery routing", () => {
it.each([
"native_session_interrupted",
"native_runner_process_exited",
"provider_transport_failed",
"provider_frame_too_large",
] as const)("keeps %s with the original runner", (cause) => {
expect(shouldRouteRecoveryToOriginalAgent(cause)).toBe(true);
});
it("does not change ordinary stranded-issue manager routing", () => {
expect(shouldRouteRecoveryToOriginalAgent("stranded_assigned_issue")).toBe(false);
});
});
+23 -4
View File
@@ -162,16 +162,34 @@ type LatestIssueRun = Pick<
} | null;
type SuccessfulLatestIssueRun = NonNullable<LatestIssueRun> & { status: "succeeded" };
type StrandedRecoveryCause =
export type StrandedRecoveryCause =
| "stranded_assigned_issue"
| "process_lost"
| "provider_quota"
| "codex_output_inactivity_monitor"
| "workspace_validation_failed"
| "configuration_incomplete"
| "native_session_interrupted"
| "native_runner_process_exited"
| "provider_transport_failed"
| "provider_frame_too_large"
| "execution_review_participant_recovery"
| typeof SUCCESSFUL_RUN_MISSING_STATE_REASON;
const NATIVE_RUNNER_RECOVERY_CAUSES = new Set<StrandedRecoveryCause>([
"native_session_interrupted",
"native_runner_process_exited",
"provider_transport_failed",
"provider_frame_too_large",
]);
export function shouldRouteRecoveryToOriginalAgent(cause: StrandedRecoveryCause): boolean {
return cause === "process_lost"
|| cause === SUCCESSFUL_RUN_MISSING_STATE_REASON
|| cause === "codex_output_inactivity_monitor"
|| NATIVE_RUNNER_RECOVERY_CAUSES.has(cause);
}
type StrandedPreviousStatus = "todo" | "in_progress" | "in_review";
type SuccessfulRunHandoffRecoveryEvidence = {
@@ -274,6 +292,9 @@ function resolveStrandedRecoveryCause(
if (latestRun?.errorCode === "codex_output_inactivity_monitor") {
return "codex_output_inactivity_monitor";
}
if (NATIVE_RUNNER_RECOVERY_CAUSES.has(latestRun?.errorCode as StrandedRecoveryCause)) {
return latestRun!.errorCode as StrandedRecoveryCause;
}
return "stranded_assigned_issue";
}
@@ -2578,9 +2599,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
}) {
const originalAgentId = input.latestRun?.agentId ?? input.issue.assigneeAgentId;
const returnOwnerAgentId = input.issue.assigneeAgentId ?? originalAgentId;
const routeToOriginal = input.recoveryCause === "process_lost" ||
input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON ||
input.recoveryCause === "codex_output_inactivity_monitor";
const routeToOriginal = shouldRouteRecoveryToOriginalAgent(input.recoveryCause);
if (input.recoveryCause === "provider_quota") {
const retryAgentId = await resolveInvokableRecoveryAgentId(input.issue, originalAgentId);
if (!retryAgentId) {