mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The cost ledger records usage and controls agent budgets. > - Some runs lose the selected model or API billing category before their usage reaches the ledger. > - The dashboard reports missing costs but does not identify the affected runs or the available actions. > - This pull request preserves usage attribution and adds a compact view of cost gaps grouped by agent. > - Operators can inspect runs, open agent settings, and record saved costs without starting new agent work. ## Linked Issues or Issue Description Refs #15634, #11938, #15879. This follows the merged subscription reporting in #15634. The older proposal in #11938 addresses part of ACP model attribution. This change also rejects conflicting models across a turn, fixes credential-backed billing capture, and explains cost gaps in the existing dashboard. The Pi receipt recovery work in #15879 covers a separate adapter recovery path. This PR preserves unresolved receipts and does not recover provider usage or reprice past entries. **What happened?** ACP results use the requested model even when the runtime selects a model. Native and OpenCode API runs can report unknown billing despite a matching API credential. The dashboard shows notices without links to the affected runs. These gaps make API and subscription totals harder to interpret. **Expected behavior** Record the model selected by the runtime. Identify API billing only from a matching credential. Keep costs unresolved when reliable usage or pricing is absent. Show the affected agents and runs. Offer an accounting retry only when saved evidence is ready. **Steps to reproduce** 1. Run an ACP agent with its model left unset, or use a matching API credential with a native or OpenCode agent. 2. Inspect the recorded model and billing category for the run. 3. Open Costs Overview for a period with unpriced usage or terminal runs awaiting receipts. 4. Inspect the notice and try to find the affected run or the relevant agent settings. **Paperclip version or commit** Current master at `657e71584a0`. The patch is rebased on that commit. **Deployment mode** Self-hosted server and local development. The cost detail tests use a disposable PostgreSQL database. ## What Changed - Preserve the runtime-selected ACP model in checkpoints and final results. Conflicting models across one turn remain unpriced. - Share credential-backed API billing checks across native and OpenCode results. Keep unknown billing when the provider and credential do not match. - Add company-scoped cost detail and accounting retry endpoints. Board viewers can read details but cannot retry. Agents and foreign companies are denied. - Limit each detail category to 25 entries. Use one consistent database snapshot and sparse indexes matching the filters and sort order. Add the unknown-billing count to the existing summary query. - Replace the notice with an expandable count line. Group entries alphabetically by agent. Show the saved blob icon, link each name to configuration, and put run and retry links beside the date. - Preserve estimate and unavailable-price labels on Providers and Billers; keep retry confirmation after the resolved notice disappears. - Fetch details only while expanded. Retain the last loaded details after a refresh error. Retry saved accounting only; do not rerun the agent. - Add capture, database, authorization, UI, and Storybook coverage. Update the implementation contract and cost reporting guide. ## Verification - `pnpm -r typecheck` passed after rebase. - `pnpm build` and `pnpm build-storybook` passed after rebase. - `pnpm check:token-gates` and `git diff --check` passed. - 1,086 focused tests passed across 17 files. These cover ACP, native and OpenCode capture, PostgreSQL reports, route authorization, OpenAPI, Costs UI, and existing accounting regressions. - Desktop and mobile browser checks passed during UI review. They cover lazy expansion, alphabetical groups, loaded blob icons, configuration and run links, keyboard retry, and layout overflow. - `pnpm test:run` was attempted with command-scoped `init.defaultBranch=master` for Git fixtures. It was stopped after a native session recovery failure reproduced on unchanged base `657e71584a0` (`archives a damaged prior epoch before completing guarded same-task replacement with real runnerd`). An MCP upload case failed in the broad run but passed in isolation on both the base and PR branch. These local limitations do not affect the 1,086 passing focused tests. - Current-head CI is green, including all unit test lanes, serialized server suites, runner checks, typecheck, build, policy, and eight browser shards. Apex re-review is 5/5 on `56de60effdb`; all four original findings are addressed and every review thread is resolved. - PostgreSQL applies migration `0333` in the integration fixtures. EXPLAIN ANALYZE on the production detail statements uses the new sparse indexes behind 5,000 healthy records in each table. - Ten provider/biller card cases cover missing and estimated prices; the retry test covers row removal and notice unmount. A live Storybook browser check verifies the confirmation and updated count. - Run `pnpm storybook`. Open **Costs / Subscriptions / Cost Data Quality Details** to inspect the production page with representative fixtures. ## Risks - Model and billing metadata change for future runs. Estimates still require complete usage and a supported price. No past cost amount is rewritten. - A missing final receipt remains unresolved for budget checks. The display changes after fifteen minutes; that time limit never authorizes settlement. - Accounting retry is a write action. It retains the existing company checks, receipt fences, idempotency, and audit logging. - Detail reads are bounded and run only after expansion. They poll every 30 seconds while open. No provider probe or new summary query is added. - Migration `0333` adds two partial indexes; it changes no stored cost amounts or table columns. Index creation scans the existing tables and may briefly block writes during deployment. No lockfile or workflow change is included. ## Model Used OpenAI Codex, based on GPT-6, with repository inspection, code editing, browser checks, and test execution. The exact serving model ID, reasoning setting, and context window were not exposed to this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
830 lines
35 KiB
TypeScript
830 lines
35 KiB
TypeScript
import { createProviderStoppedBoundary } from "@paperclipai/adapter-utils/provider-stopped-boundary";
|
|
import { createUsageCheckpointLog } from "@paperclipai/adapter-utils/usage-checkpoint";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
|
import {
|
|
adapterExecutionTargetIsRemote,
|
|
adapterExecutionTargetRemoteCwd,
|
|
overrideAdapterExecutionTargetRemoteCwd,
|
|
adapterExecutionTargetSessionIdentity,
|
|
adapterExecutionTargetSessionMatches,
|
|
adapterExecutionTargetUsesManagedHome,
|
|
adapterExecutionTargetUsesPaperclipBridge,
|
|
describeAdapterExecutionTarget,
|
|
ensureAdapterExecutionTargetCommandResolvable,
|
|
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
|
prepareAdapterExecutionTargetRuntime,
|
|
adapterExecutionTargetDuplexObservabilityRecorder,
|
|
adapterExecutionTargetEnablesSandboxDuplexBridge,
|
|
readAdapterExecutionTarget,
|
|
readAdapterExecutionTargetHomeDir,
|
|
resolveAdapterExecutionTargetTimeout,
|
|
resolveAdapterExecutionTargetCommandForLogs,
|
|
runAdapterExecutionTargetProcess,
|
|
runAdapterExecutionTargetShellCommand,
|
|
startAdapterExecutionTargetPaperclipBridge,
|
|
} from "@paperclipai/adapter-utils/execution-target";
|
|
import {
|
|
asString,
|
|
asNumber,
|
|
asStringArray,
|
|
parseObject,
|
|
buildPaperclipEnv,
|
|
buildRuntimeToolsEnv,
|
|
joinPromptSections,
|
|
buildInvocationEnvForLogs,
|
|
ensureAbsoluteDirectory,
|
|
ensurePaperclipSkillSymlink,
|
|
ensurePathInEnv,
|
|
refreshPaperclipWorkspaceEnvForExecution,
|
|
renderTemplate,
|
|
hydrateFreshSessionHandoff,
|
|
selectPaperclipPromptSections,
|
|
selectInitialCommunicationGuidance,
|
|
isPaperclipRecoveryWakePayload,
|
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
|
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
|
runChildProcess,
|
|
isPaperclipSkillSourceMissing,
|
|
readPaperclipRuntimeSkillEntries,
|
|
readPaperclipIssueWorkModeFromContext,
|
|
resolveLegacyPaperclipDesiredSkillNames,
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
|
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl, createOpenCodeJsonlParser } from "./parse.js";
|
|
import {
|
|
ensureOpenCodeModelConfiguredAndAvailable,
|
|
isTruthyEnvFlag,
|
|
parseOpenCodeModelsOutput,
|
|
requireOpenCodeModelId,
|
|
} from "./models.js";
|
|
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
|
|
import { apiKeyBillingType } from "@paperclipai/adapter-utils/api-billing-type";
|
|
import { prepareOpenCodeRuntimeConfig, prepareManagedOpenCodeRemoteHomes } from "./runtime-config.js";
|
|
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
|
import { resolveOpenCodeSkillsHome } from "./skills.js";
|
|
|
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
function firstNonEmptyLine(text: string): string {
|
|
return (
|
|
text
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.find(Boolean) ?? ""
|
|
);
|
|
}
|
|
|
|
function parseModelProvider(model: string | null): string | null {
|
|
if (!model) return null;
|
|
const trimmed = model.trim();
|
|
if (!trimmed.includes("/")) return null;
|
|
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
|
|
}
|
|
|
|
function resolveOpenCodeBiller(env: Record<string, string>, provider: string | null): string {
|
|
return provider === "openai" ? inferOpenAiCompatibleBiller(env, "openai") ?? "unknown" : provider ?? "unknown";
|
|
}
|
|
|
|
const REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC = 20;
|
|
const REMOTE_OPENCODE_MODELS_PROBE_SANDBOX_TIMEOUT_SEC = 120;
|
|
|
|
export async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: {
|
|
runId: string;
|
|
executionTarget: NonNullable<AdapterExecutionContext["executionTarget"]>;
|
|
command: string;
|
|
model: string;
|
|
cwd: string;
|
|
env: Record<string, string>;
|
|
timeoutSec: number;
|
|
graceSec: number;
|
|
}) {
|
|
const model = requireOpenCodeModelId(input.model);
|
|
|
|
// When the caller opts into OPENCODE_ALLOW_ALL_MODELS, OpenCode accepts any
|
|
// provider/model at run time (e.g. gateway-routed models that never appear in
|
|
// `opencode models` output). Honour that on the REMOTE path too by skipping the
|
|
// remote availability probe; we still enforce the provider/model format above.
|
|
// Mirrors the local ensureOpenCodeModelConfiguredAndAvailable bypass. Prefer the
|
|
// explicit run env, then the process env.
|
|
if (isTruthyEnvFlag(input.env.OPENCODE_ALLOW_ALL_MODELS ?? process.env.OPENCODE_ALLOW_ALL_MODELS)) {
|
|
return;
|
|
}
|
|
|
|
const defaultProbeTimeoutSec =
|
|
input.executionTarget.kind === "remote" && input.executionTarget.transport === "sandbox"
|
|
? REMOTE_OPENCODE_MODELS_PROBE_SANDBOX_TIMEOUT_SEC
|
|
: REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC;
|
|
const probeTimeoutSec = input.timeoutSec > 0
|
|
? Math.min(input.timeoutSec, defaultProbeTimeoutSec)
|
|
: defaultProbeTimeoutSec;
|
|
const probe = await runAdapterExecutionTargetProcess(
|
|
input.runId,
|
|
input.executionTarget,
|
|
input.command,
|
|
["models"],
|
|
{
|
|
cwd: input.cwd,
|
|
env: input.env,
|
|
timeoutSec: probeTimeoutSec,
|
|
graceSec: input.graceSec,
|
|
onLog: async () => {},
|
|
},
|
|
);
|
|
|
|
// The remote availability probe is a best-effort pre-flight guard, not a gate.
|
|
// If `opencode models` itself cannot run on the target — timeout, transient CLI
|
|
// error, provider hiccup — do NOT abort the run. The real invocation is
|
|
// authoritative, so a probe that can't execute must never be fatal. (Previously
|
|
// these threw and crashed runs mid-flight, losing the agent's work + disposition.)
|
|
if (probe.timedOut) {
|
|
console.warn(
|
|
`[opencode-local] Remote model availability probe for "${model}" timed out after ${probeTimeoutSec}s; proceeding with the configured model.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if ((probe.exitCode ?? 1) !== 0) {
|
|
const detail = firstNonEmptyLine(probe.stderr) || firstNonEmptyLine(probe.stdout);
|
|
console.warn(
|
|
`[opencode-local] Remote \`opencode models\` could not run for "${model}"${
|
|
detail ? ` (${detail})` : ""
|
|
}; proceeding with the configured model.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const models = parseOpenCodeModelsOutput(probe.stdout);
|
|
if (models.length === 0) {
|
|
console.warn(
|
|
`[opencode-local] Remote \`opencode models\` returned no models; proceeding with the configured model "${model}".`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!models.some((entry) => entry.id === model)) {
|
|
const sample = models.slice(0, 12).map((entry) => entry.id).join(", ");
|
|
throw new Error(
|
|
`Configured OpenCode model is unavailable on the remote execution target: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function ensureOpenCodeSkillsInjected(
|
|
onLog: AdapterExecutionContext["onLog"],
|
|
skillsEntries: Array<{ key: string; runtimeName: string; source: string }>,
|
|
desiredSkillNames?: string[],
|
|
skillsHome = resolveOpenCodeSkillsHome({}),
|
|
) {
|
|
await fs.mkdir(skillsHome, { recursive: true });
|
|
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key));
|
|
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key));
|
|
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
|
skillsHome,
|
|
selectedEntries.map((entry) => entry.runtimeName),
|
|
);
|
|
for (const skillName of removedSkills) {
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Removed maintainer-only OpenCode skill "${skillName}" from ${skillsHome}\n`,
|
|
);
|
|
}
|
|
for (const entry of selectedEntries) {
|
|
const target = path.join(skillsHome, entry.runtimeName);
|
|
|
|
try {
|
|
const result = await ensurePaperclipSkillSymlink(entry.source, target);
|
|
if (result === "skipped") continue;
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} OpenCode skill "${entry.key}" into ${skillsHome}\n`,
|
|
);
|
|
} catch (err) {
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Failed to inject OpenCode skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function buildOpenCodeSkillsDir(config: Record<string, unknown>): Promise<string> {
|
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-skills-"));
|
|
const target = path.join(tmp, "skills");
|
|
await fs.mkdir(target, { recursive: true });
|
|
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
|
const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries));
|
|
for (const entry of availableEntries) {
|
|
if (!desiredNames.has(entry.key)) continue;
|
|
if (isPaperclipSkillSourceMissing(entry)) continue;
|
|
await fs.symlink(entry.source, path.join(target, entry.runtimeName));
|
|
}
|
|
return target;
|
|
}
|
|
|
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
|
const providerStop = createProviderStoppedBoundary(ctx.onProviderStopped);
|
|
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
|
const executionTarget = readAdapterExecutionTarget({
|
|
executionTarget: ctx.executionTarget,
|
|
legacyRemoteExecution: ctx.executionTransport?.remoteExecution,
|
|
});
|
|
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
|
|
|
|
const promptTemplate = asString(
|
|
config.promptTemplate,
|
|
context.conversationMode === true
|
|
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
|
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
|
);
|
|
const command = asString(config.command, "opencode");
|
|
const model = asString(config.model, "").trim();
|
|
const variant = asString(config.variant, "").trim();
|
|
|
|
const workspaceContext = parseObject(context.paperclipWorkspace);
|
|
const workspaceCwd = asString(workspaceContext.cwd, "");
|
|
const workspaceSource = asString(workspaceContext.source, "");
|
|
const workspaceId = asString(workspaceContext.workspaceId, "");
|
|
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
|
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
|
const agentHome = asString(workspaceContext.agentHome, "");
|
|
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
|
? context.paperclipWorkspaces.filter(
|
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
|
)
|
|
: [];
|
|
const configuredCwd = asString(config.cwd, "");
|
|
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
|
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
|
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
|
const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
|
const desiredOpenCodeSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, openCodeSkillEntries);
|
|
if (!executionTargetIsRemote) {
|
|
await ensureOpenCodeSkillsInjected(
|
|
onLog,
|
|
openCodeSkillEntries,
|
|
desiredOpenCodeSkillNames,
|
|
resolveOpenCodeSkillsHome(config),
|
|
);
|
|
}
|
|
|
|
const envConfig = parseObject(config.env);
|
|
const env: Record<string, string> = {
|
|
...buildPaperclipEnv(agent, ctx.agentIdentity),
|
|
...buildRuntimeToolsEnv(ctx.runtimeTools),
|
|
};
|
|
env.PAPERCLIP_RUN_ID = runId;
|
|
const wakeTaskId =
|
|
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
|
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
|
null;
|
|
const wakeReason =
|
|
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
|
? context.wakeReason.trim()
|
|
: null;
|
|
const wakeCommentId =
|
|
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
|
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
|
null;
|
|
const approvalId =
|
|
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
|
? context.approvalId.trim()
|
|
: null;
|
|
const approvalStatus =
|
|
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
|
? context.approvalStatus.trim()
|
|
: null;
|
|
const linkedIssueIds = Array.isArray(context.issueIds)
|
|
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
: [];
|
|
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
|
|
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
|
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
|
|
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
|
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
|
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
|
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
|
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
|
refreshPaperclipWorkspaceEnvForExecution({
|
|
env,
|
|
envConfig,
|
|
workspaceCwd: effectiveWorkspaceCwd,
|
|
workspaceSource,
|
|
workspaceId,
|
|
workspaceRepoUrl,
|
|
workspaceRepoRef,
|
|
workspaceHints,
|
|
agentHome,
|
|
executionTargetIsRemote,
|
|
executionCwd: effectiveExecutionCwd,
|
|
});
|
|
// Prevent OpenCode from writing an opencode.json config file into the
|
|
// project working directory (which would pollute the git repo). Model
|
|
// selection is already handled via the --model CLI flag. Set after the
|
|
// envConfig loop so user overrides cannot disable this guard.
|
|
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
|
|
if (authToken) {
|
|
env.PAPERCLIP_API_KEY = authToken;
|
|
}
|
|
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
|
|
const localRuntimeConfigHome =
|
|
preparedRuntimeConfig.notes.length > 0 ? preparedRuntimeConfig.env.XDG_CONFIG_HOME : "";
|
|
try {
|
|
const runtimeEnv = Object.fromEntries(
|
|
Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter(
|
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
),
|
|
);
|
|
const adapterExecutionTimeout = resolveAdapterExecutionTargetTimeout(
|
|
executionTarget,
|
|
asNumber(config.timeoutSec, 0),
|
|
);
|
|
const timeoutSec = adapterExecutionTimeout.timeoutSec;
|
|
const graceSec = asNumber(config.graceSec, 20);
|
|
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
|
runId,
|
|
target: executionTarget,
|
|
installCommand: ctx.runtimeCommandSpec?.installCommand,
|
|
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
|
|
cwd,
|
|
env: runtimeEnv,
|
|
timeoutSec,
|
|
graceSec,
|
|
onLog,
|
|
});
|
|
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, {
|
|
installCommand: SANDBOX_INSTALL_COMMAND,
|
|
timeoutSec,
|
|
});
|
|
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
|
let loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
|
|
runtimeEnv,
|
|
includeRuntimeKeys: ["HOME"],
|
|
resolvedCommand,
|
|
});
|
|
if (!executionTargetIsRemote) {
|
|
await ensureOpenCodeModelConfiguredAndAvailable({
|
|
model,
|
|
command,
|
|
cwd,
|
|
env: runtimeEnv,
|
|
});
|
|
}
|
|
|
|
const extraArgs = (() => {
|
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
|
return asStringArray(config.args);
|
|
})();
|
|
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
|
|
let localSkillsDir: string | null = null;
|
|
let remoteRuntimeRootDir: string | null = null;
|
|
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
|
|
|
|
if (executionTarget?.kind === "remote") {
|
|
localSkillsDir = await buildOpenCodeSkillsDir(config);
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Syncing workspace and OpenCode runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
|
|
);
|
|
const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({
|
|
runId,
|
|
target: executionTarget,
|
|
adapterKey: "opencode",
|
|
timeoutSec,
|
|
workspaceLocalDir: cwd,
|
|
installCommand: SANDBOX_INSTALL_COMMAND,
|
|
detectCommand: command,
|
|
onProgress: (line) => onLog("stdout", line),
|
|
onRuntimeProgress: ctx.onRuntimeProgress,
|
|
assets: [
|
|
{
|
|
key: "skills",
|
|
localDir: localSkillsDir,
|
|
followSymlinks: true,
|
|
},
|
|
...(localRuntimeConfigHome
|
|
? [{
|
|
key: "xdgConfig",
|
|
localDir: localRuntimeConfigHome,
|
|
}]
|
|
: []),
|
|
],
|
|
});
|
|
restoreRemoteWorkspace = () =>
|
|
preparedExecutionTargetRuntime.restoreWorkspace((line) => onLog("stdout", line));
|
|
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd;
|
|
refreshPaperclipWorkspaceEnvForExecution({
|
|
env: preparedRuntimeConfig.env,
|
|
envConfig,
|
|
workspaceCwd: effectiveWorkspaceCwd,
|
|
workspaceSource,
|
|
workspaceId,
|
|
workspaceRepoUrl,
|
|
workspaceRepoRef,
|
|
workspaceHints,
|
|
agentHome,
|
|
executionTargetIsRemote,
|
|
executionCwd: effectiveExecutionCwd,
|
|
});
|
|
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
|
|
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
|
|
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
|
|
preparedRuntimeConfig.env.HOME = preparedExecutionTargetRuntime.runtimeRootDir;
|
|
}
|
|
if (localRuntimeConfigHome && preparedExecutionTargetRuntime.assetDirs.xdgConfig) {
|
|
preparedRuntimeConfig.env.XDG_CONFIG_HOME = preparedExecutionTargetRuntime.assetDirs.xdgConfig;
|
|
}
|
|
prepareManagedOpenCodeRemoteHomes({
|
|
env: preparedRuntimeConfig.env,
|
|
config,
|
|
runtimeRootDir: preparedExecutionTargetRuntime.runtimeRootDir,
|
|
runId,
|
|
configDir: preparedExecutionTargetRuntime.assetDirs.xdgConfig,
|
|
});
|
|
const remoteHomeDir = config.managedAiConnection
|
|
? preparedRuntimeConfig.env.HOME
|
|
: managedHome && preparedExecutionTargetRuntime.runtimeRootDir
|
|
? preparedExecutionTargetRuntime.runtimeRootDir
|
|
: await readAdapterExecutionTargetHomeDir(runId, executionTarget, {
|
|
cwd,
|
|
env: preparedRuntimeConfig.env,
|
|
timeoutSec,
|
|
graceSec,
|
|
onLog,
|
|
});
|
|
if (remoteHomeDir && preparedExecutionTargetRuntime.assetDirs.skills) {
|
|
const remoteSkillsDir = path.posix.join(remoteHomeDir, ".claude", "skills");
|
|
await runAdapterExecutionTargetShellCommand(
|
|
runId,
|
|
executionTarget,
|
|
`mkdir -p ${JSON.stringify(path.posix.dirname(remoteSkillsDir))} && rm -rf ${JSON.stringify(remoteSkillsDir)} && cp -a ${JSON.stringify(preparedExecutionTargetRuntime.assetDirs.skills)} ${JSON.stringify(remoteSkillsDir)}`,
|
|
{ cwd, env: preparedRuntimeConfig.env, timeoutSec, graceSec, onLog },
|
|
);
|
|
}
|
|
await ensureRemoteOpenCodeModelConfiguredAndAvailable({
|
|
runId,
|
|
executionTarget,
|
|
command,
|
|
model,
|
|
cwd,
|
|
env: preparedRuntimeConfig.env,
|
|
timeoutSec,
|
|
graceSec,
|
|
});
|
|
}
|
|
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
|
|
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) {
|
|
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
|
|
runId,
|
|
target: runtimeExecutionTarget,
|
|
enableSandboxDuplexBridge: adapterExecutionTargetEnablesSandboxDuplexBridge(runtimeExecutionTarget),
|
|
duplexObservabilityRecorder: adapterExecutionTargetDuplexObservabilityRecorder(runtimeExecutionTarget),
|
|
runtimeRootDir: remoteRuntimeRootDir,
|
|
adapterKey: "opencode",
|
|
timeoutSec,
|
|
hostApiToken: preparedRuntimeConfig.env.PAPERCLIP_API_KEY,
|
|
onLog,
|
|
});
|
|
if (paperclipBridge) {
|
|
Object.assign(preparedRuntimeConfig.env, paperclipBridge.env);
|
|
loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
|
|
runtimeEnv: Object.fromEntries(
|
|
Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter(
|
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
),
|
|
),
|
|
includeRuntimeKeys: ["HOME"],
|
|
resolvedCommand,
|
|
});
|
|
}
|
|
}
|
|
|
|
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
|
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
|
|
const canResumeSession =
|
|
runtimeSessionId.length > 0 &&
|
|
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
|
|
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
|
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
|
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] OpenCode session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
|
|
);
|
|
} else if (runtimeSessionId && !canResumeSession) {
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
|
|
);
|
|
}
|
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
|
const resolvedInstructionsFilePath = instructionsFilePath
|
|
? path.resolve(cwd, instructionsFilePath)
|
|
: "";
|
|
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
|
|
let instructionsPrefix = "";
|
|
if (resolvedInstructionsFilePath) {
|
|
try {
|
|
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
|
|
instructionsPrefix =
|
|
`${instructionsContents}\n\n` +
|
|
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const commandNotes = (() => {
|
|
const notes = [...preparedRuntimeConfig.notes];
|
|
if (!resolvedInstructionsFilePath) return notes;
|
|
if (instructionsPrefix.length > 0) {
|
|
notes.push(`Loaded agent instructions from ${resolvedInstructionsFilePath}`);
|
|
notes.push(
|
|
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
|
);
|
|
return notes;
|
|
}
|
|
notes.push(
|
|
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
|
);
|
|
return notes;
|
|
})();
|
|
|
|
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
|
const templateData = {
|
|
agentId: agent.id,
|
|
companyId: agent.companyId,
|
|
runId,
|
|
company: { id: agent.companyId },
|
|
agent,
|
|
run: { id: runId, source: "on_demand" },
|
|
context,
|
|
};
|
|
const buildPrompt = (resumedSession: boolean) => {
|
|
const renderedBootstrapPrompt =
|
|
!resumedSession && bootstrapPromptTemplate.trim().length > 0
|
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
|
: "";
|
|
const { taskContextNote, wakePrompt } = selectPaperclipPromptSections(context, {
|
|
resumedSession,
|
|
includeCommunicationGuidance: false,
|
|
});
|
|
const shouldUseResumeDeltaPrompt = resumedSession && wakePrompt.length > 0;
|
|
const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake)
|
|
? ""
|
|
: renderTemplate(promptTemplate, templateData);
|
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
|
const basePrompt = joinPromptSections([
|
|
instructionsPrefix,
|
|
renderedBootstrapPrompt,
|
|
wakePrompt,
|
|
taskContextNote,
|
|
sessionHandoffNote,
|
|
renderedPrompt,
|
|
]);
|
|
return {
|
|
basePrompt,
|
|
promptMetrics: {
|
|
promptChars: basePrompt.length,
|
|
instructionsChars: instructionsPrefix.length,
|
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
|
wakePromptChars: wakePrompt.length,
|
|
taskContextChars: taskContextNote.length,
|
|
sessionHandoffChars: sessionHandoffNote.length,
|
|
heartbeatPromptChars: renderedPrompt.length,
|
|
},
|
|
};
|
|
};
|
|
|
|
// Optional diagnostic: surface OpenCode's own logs on stderr (captured into the
|
|
// run result) so failures that OpenCode otherwise wraps as an opaque
|
|
// "Unexpected server error" can be diagnosed in remote/sandbox runs where the
|
|
// log file is unreachable. Toggle via PAPERCLIP_OPENCODE_PRINT_LOGS (run env,
|
|
// then process env).
|
|
const printLogs = isTruthyEnvFlag(
|
|
env.PAPERCLIP_OPENCODE_PRINT_LOGS ?? process.env.PAPERCLIP_OPENCODE_PRINT_LOGS,
|
|
);
|
|
const buildArgs = (resumeSessionId: string | null) => {
|
|
const args = ["run", "--format", "json"];
|
|
if (printLogs) args.push("--print-logs");
|
|
if (resumeSessionId) args.push("--session", resumeSessionId);
|
|
if (model) args.push("--model", model);
|
|
if (variant) args.push("--variant", variant);
|
|
// OpenCode prefers the inherited PWD over process.cwd(). Use the realized
|
|
// workspace unless the operator already selected a directory explicitly.
|
|
const optionTerminator = extraArgs.indexOf("--");
|
|
const extraOptionArgs = optionTerminator === -1 ? extraArgs : extraArgs.slice(0, optionTerminator);
|
|
if (!extraOptionArgs.some((arg) => arg === "--dir" || arg.startsWith("--dir="))) {
|
|
args.push("--dir", effectiveExecutionCwd);
|
|
}
|
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
|
return args;
|
|
};
|
|
|
|
// Remote targets receive only the prepared environment, never host keys.
|
|
const accountingEnv = executionTargetIsRemote ? preparedRuntimeConfig.env : runtimeEnv;
|
|
const runAttempt = async (resumeSessionId: string | null) => {
|
|
await hydrateFreshSessionHandoff(ctx, { resumedSession: Boolean(resumeSessionId) });
|
|
const { basePrompt, promptMetrics } = buildPrompt(Boolean(resumeSessionId));
|
|
const prompt = joinPromptSections([
|
|
selectInitialCommunicationGuidance(context, { resumedSession: Boolean(resumeSessionId) }),
|
|
basePrompt,
|
|
]);
|
|
const args = buildArgs(resumeSessionId);
|
|
if (onMeta) {
|
|
await onMeta({
|
|
adapterType: "opencode_local",
|
|
command: resolvedCommand,
|
|
cwd: effectiveExecutionCwd,
|
|
commandNotes,
|
|
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
|
env: loggedEnv,
|
|
prompt,
|
|
promptMetrics: { ...promptMetrics, promptChars: prompt.length },
|
|
context,
|
|
});
|
|
}
|
|
|
|
const consumeAccounting = createOpenCodeJsonlParser();
|
|
let hasAccounting = false;
|
|
const accountingLog = createUsageCheckpointLog(onLog, ctx.onUsage ?? (async () => {}), stdout => {
|
|
hasAccounting = true;
|
|
const parsed = consumeAccounting(stdout);
|
|
const provider = parseModelProvider(model || null);
|
|
return { usage: parsed.usageReported ? parsed.usage : undefined, costUsd: parsed.costUsd,
|
|
costStatus: parsed.usageComplete || parsed.costUsd != null ? undefined : "unpriced",
|
|
usageBasis: "per_run", provider, biller: resolveOpenCodeBiller(accountingEnv, provider), billingType: apiKeyBillingType(provider, accountingEnv), model, complete: false };
|
|
});
|
|
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
|
|
onProcessStopped: providerStop.beginInvocation(),
|
|
// Keep a long sandbox run off the synchronous command HTTP request.
|
|
// Probes and preparation keep their existing execution paths.
|
|
...(runtimeExecutionTarget?.kind === "remote" && runtimeExecutionTarget.transport === "sandbox"
|
|
&& runtimeExecutionTarget.providerKey === "daytona" ? { useSession: true } : {}),
|
|
cwd,
|
|
env: preparedRuntimeConfig.env,
|
|
stdin: prompt,
|
|
timeoutSec,
|
|
graceSec,
|
|
onSpawn,
|
|
onRuntimeProgress: ctx.onRuntimeProgress,
|
|
onLog: accountingLog,
|
|
runLogTail: paperclipBridge?.runLogTail,
|
|
settleRunDisposition: paperclipBridge?.settleRunDisposition,
|
|
});
|
|
// Parse any unterminated final record before deciding whether its usage
|
|
// is complete. A clean exit alone cannot turn absent counters into zero.
|
|
await accountingLog.flush();
|
|
const retainedAccounting = consumeAccounting("");
|
|
await accountingLog.flush({ complete: proc.exitCode === 0 && !proc.timedOut && !proc.signal
|
|
&& (retainedAccounting.usageComplete || retainedAccounting.costUsd != null) });
|
|
// Display output is capped by the process transport. Keep accounting
|
|
// from the full stream, including when no checkpoint callback is installed.
|
|
const parsed = parseOpenCodeJsonl(proc.stdout);
|
|
if (hasAccounting) {
|
|
const retained = consumeAccounting("");
|
|
parsed.usage = retained.usage;
|
|
parsed.usageReported = retained.usageReported;
|
|
parsed.usageComplete = retained.usageComplete;
|
|
parsed.costUsd = retained.costUsd;
|
|
}
|
|
return { proc, rawStderr: proc.stderr, parsed };
|
|
};
|
|
|
|
const toResult = (
|
|
attempt: {
|
|
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string; errorCode?: string | null };
|
|
rawStderr: string;
|
|
parsed: ReturnType<typeof parseOpenCodeJsonl>;
|
|
},
|
|
clearSessionOnMissingSession = false,
|
|
): AdapterExecutionResult => {
|
|
if (attempt.proc.timedOut) {
|
|
return {
|
|
exitCode: attempt.proc.exitCode,
|
|
signal: attempt.proc.signal,
|
|
timedOut: true,
|
|
usageComplete: false,
|
|
usage: attempt.parsed.usageReported ? attempt.parsed.usage : undefined,
|
|
usageBasis: "per_run",
|
|
provider: parseModelProvider(model || null),
|
|
biller: resolveOpenCodeBiller(accountingEnv, parseModelProvider(model || null)),
|
|
model,
|
|
billingType: apiKeyBillingType(parseModelProvider(model || null), accountingEnv),
|
|
costUsd: attempt.parsed.costUsd,
|
|
costStatus: attempt.parsed.usageComplete || attempt.parsed.costUsd != null ? undefined : "unpriced",
|
|
// A provider or output-observation timeout can arrive before the
|
|
// configured execution deadline. The boolean does not prove which
|
|
// timer fired, and provider stderr is not safe exception text.
|
|
errorMessage: "OpenCode execution timed out",
|
|
resultJson: { adapterExecutionTimeout },
|
|
clearSession: clearSessionOnMissingSession,
|
|
};
|
|
}
|
|
|
|
const resolvedSessionId =
|
|
attempt.parsed.sessionId ??
|
|
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
|
|
const resolvedSessionParams = resolvedSessionId
|
|
? ({
|
|
sessionId: resolvedSessionId,
|
|
cwd: effectiveExecutionCwd,
|
|
...(workspaceId ? { workspaceId } : {}),
|
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
|
...(executionTargetIsRemote
|
|
? {
|
|
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
|
|
}
|
|
: {}),
|
|
} as Record<string, unknown>)
|
|
: null;
|
|
|
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
|
const rawExitCode = attempt.proc.exitCode;
|
|
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
|
|
const fallbackErrorMessage =
|
|
parsedError ||
|
|
stderrLine ||
|
|
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
|
|
const modelId = model || null;
|
|
|
|
return {
|
|
exitCode: synthesizedExitCode,
|
|
signal: attempt.proc.signal,
|
|
timedOut: false,
|
|
usageComplete: attempt.proc.exitCode === 0 && !attempt.proc.signal
|
|
&& (attempt.parsed.usageComplete || attempt.parsed.costUsd != null),
|
|
usageBasis: "per_run",
|
|
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
|
// Forward the transport-level error code from the run-disposition seam.
|
|
// A lost duplex control channel surfaces the typed `duplex_channel_lost`
|
|
// code; every other result carries no code here.
|
|
errorCode: attempt.proc.errorCode ?? null,
|
|
usage: attempt.parsed.usageReported ? attempt.parsed.usage : undefined,
|
|
sessionId: resolvedSessionId,
|
|
sessionParams: resolvedSessionParams,
|
|
sessionDisplayId: resolvedSessionId,
|
|
provider: parseModelProvider(modelId),
|
|
biller: resolveOpenCodeBiller(accountingEnv, parseModelProvider(modelId)),
|
|
model: modelId,
|
|
billingType: apiKeyBillingType(parseModelProvider(modelId), accountingEnv),
|
|
costUsd: attempt.parsed.costUsd,
|
|
costStatus: attempt.parsed.usageComplete || attempt.parsed.costUsd != null ? undefined : "unpriced",
|
|
resultJson: {
|
|
adapterExecutionTimeout,
|
|
stdout: attempt.proc.stdout,
|
|
stderr: attempt.proc.stderr,
|
|
},
|
|
summary: attempt.parsed.summary,
|
|
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
|
|
};
|
|
};
|
|
|
|
try {
|
|
const initial = await runAttempt(sessionId);
|
|
const initialFailed =
|
|
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
|
|
if (
|
|
sessionId &&
|
|
initialFailed &&
|
|
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
|
) {
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
|
);
|
|
const retry = await runAttempt(null);
|
|
return toResult(retry, true);
|
|
}
|
|
|
|
return toResult(initial);
|
|
} finally {
|
|
try {
|
|
await providerStop.collectBeforeRestore();
|
|
} finally {
|
|
await Promise.all([
|
|
paperclipBridge?.stop(),
|
|
restoreRemoteWorkspace?.(),
|
|
localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(),
|
|
]);
|
|
}
|
|
}
|
|
} finally {
|
|
await preparedRuntimeConfig.cleanup();
|
|
}
|
|
}
|