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 Legacy OpenCode adapter runs an agent and its shell tools in the same sandbox. > - A large build can exceed the sandbox memory limit and trigger a kernel OOM kill. > - The kernel can kill OpenCode before its tool, which loses the active turn. > - OpenCode provides a shell environment hook that can change only the tool subprocess. > - This pull request uses that hook to prefer Bash tool processes as OOM victims. > - The benefit is that OpenCode can report a failed tool and continue the task. ## Linked Issues or Issue Description **What happened?** An OpenCode sandbox run ended with exit 137 after a TypeScript process had also been killed. Its cgroup limit was 4 GiB. It had completed useful tool work and had not reached its execution deadline. The original sandbox was deleted, so its historical OOM counters are unavailable. Memory pressure is the leading explanation, not a proven historical kernel diagnosis. **Expected behavior** When a shell tool exhausts sandbox memory, the kernel should prefer that tool over OpenCode where possible. The adapter must preserve configured shell startup, model, permissions and resource limits. **Steps to reproduce** Use a disposable 4 GiB Linux sandbox with OpenCode. Retain a 1,200 MiB buffer in the agent and run a Bash tool with fourteen 250 MiB workers. Compare the same workload before and after installing the generated hook. Record the cgroup memory events, agent exit and final response. Refs: #16844. That PR covers the same protection for Claude. This PR adds the OpenCode-owned hook. Refs: #17254 for the separate shutdown recovery path. This PR does not change retry policy. ## What Changed - Generate a dependency-free OpenCode `shell.env` plugin and a Bash startup file in the isolated runtime config. - Stage those assets only for sandbox execution. Resolve the startup file relative to the copied plugin. - Set child `oom_score_adj` to `1000` on Linux. Keep the parent environment unchanged and honor existing `BASH_ENV` values, including an empty value. - Keep command execution available if the optional plugin cannot be installed or the process priority cannot be changed. - Materialize linked plugin directories inside the isolated config before writing. Keep the operator directory and its plugins unchanged. - Cover relocation, shell output and exit status, operator settings, local execution, remote staging and failed installation. Update the adapter documentation and verification recipe. ## Verification - Red before the fix: the new runtime regression failed because the sandbox shell plugin was absent. The existing 15 runtime config tests passed. The same regression passes with the fix. - Apex first found a host-write risk through linked plugins directories (4/5). A new regression reproduced the host directory modification, then passed after the installer materialized the copied link. The finding is resolved. Apex reviewed current head `df82a8f51725a197c0ea3ebeb942488509f32b34` at 5/5 with no actionable issues remaining. - The full OpenCode adapter suite passes: 87 source tests. The adapter typecheck and map checks pass. - Real sandbox acceptance used the plugin generated from `df82a8f51725a197c0ea3ebeb942488509f32b34` and OpenCode 1.18.34 in a disposable 4 GiB Daytona sandbox. A controlled local model endpoint issued an actual Bash tool call; no real model credentials were used. The tool reported priority `1000`; the parent reported `0`. - Controlled OOM comparison: without the guard, OpenCode was killed by SIGKILL after 8.95 seconds and the cgroup recorded one OOM kill. With the guard, five workers were killed, OpenCode exited `0` after 21.87 seconds, and its final response was `fixture-complete`. The cgroup counter increased by five. Both attempts used the same allocation and workload. The fixture started with a linked operator plugins directory. The installer preserved that directory, staged a real plugins directory, and passed both the process-priority probe and OOM comparison. The fixture allocation was deleted afterward. - The first live probe was not accepted because the harness could not parse the provider response framing. After correcting the decoder, the unchanged plugin passed the probe and the controlled OOM comparison. Both initial disposable allocations were deleted. After the review fix, a third allocation repeated the probe and OOM comparison with a linked plugins directory. That allocation was also deleted. The initial comparison had the same agent-killed/agent-survived result (8.42 seconds without the guard; 19.42 seconds with it). - Repository `pnpm -r typecheck` and `pnpm build` pass. The full CI matrix passes on `df82a8f51725a197c0ea3ebeb942488509f32b34`, including tests, browser shards, Runner checks, build, policy and Apex. [CI run](https://github.com/paperclipai/paperclip/actions/runs/40043947375). - The first full local test attempt had 15 embedded PostgreSQL startup failures. Direct `initdb --version` and `postgres --version` probes showed missing bundled library symlinks and SIGABRT. Running the bundled symlink hydration script restored both executables. All 15 affected cases pass after that setup repair. No product code changed for it. - Two existing workspace tests assumed that new Git repositories have no `main` branch. They failed under the host default branch setting of `main`, then passed with a per-command `init.defaultBranch=master` setting. The global setting was preserved. The full local test command was restarted with hydrated libraries and that controlled fixture default. It had no reported assertion failures when it was stopped after the complete CI matrix passed. It did not complete locally; full-suite verification comes from CI. The first failed invocation was also stopped. - The original tenant task has not been retried or recovered by this PR. Its board API credential is expired. The original hosted workflow and the separate shutdown recovery remain outside this acceptance result. ## Risks - This is a preference, not a memory reservation. It cannot guarantee that OpenCode survives every OOM condition, including memory growth inside OpenCode itself. - The guard applies to non-interactive Bash tools. Other shells ignore `BASH_ENV`. Existing operator startup settings take precedence. - Older OpenCode releases without the shell environment hook will not apply the preference. No model, permission, deadline, memory quota or retry limit changes. - No schema change or migration. ## Model Used OpenAI GPT-6 through Codex, with repository tools and code execution. The exact deployment variant and context window are not exposed by 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: Paperclip <noreply@paperclip.ing>
833 lines
35 KiB
TypeScript
833 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,
|
|
sandboxToolOomPreference: executionTarget?.kind === "remote" && executionTarget.transport === "sandbox",
|
|
});
|
|
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();
|
|
}
|
|
}
|