@paperclipai/adapter-utils 2026.824.0 → 2026.825.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acpx-engine/composed-run-characterization.test.js +12 -13
- package/dist/acpx-engine/composed-run-characterization.test.js.map +1 -1
- package/dist/acpx-engine/constants.d.ts +1 -0
- package/dist/acpx-engine/constants.d.ts.map +1 -1
- package/dist/acpx-engine/constants.js +1 -0
- package/dist/acpx-engine/constants.js.map +1 -1
- package/dist/acpx-engine/execute-identity.test.d.ts +2 -0
- package/dist/acpx-engine/execute-identity.test.d.ts.map +1 -0
- package/dist/acpx-engine/execute-identity.test.js +80 -0
- package/dist/acpx-engine/execute-identity.test.js.map +1 -0
- package/dist/acpx-engine/execute.d.ts +48 -22
- package/dist/acpx-engine/execute.d.ts.map +1 -1
- package/dist/acpx-engine/execute.js +1216 -883
- package/dist/acpx-engine/execute.js.map +1 -1
- package/dist/acpx-engine/execute.test.js +706 -37
- package/dist/acpx-engine/execute.test.js.map +1 -1
- package/dist/acpx-engine/run-contracts.d.ts +407 -0
- package/dist/acpx-engine/run-contracts.d.ts.map +1 -0
- package/dist/acpx-engine/run-contracts.js +20 -0
- package/dist/acpx-engine/run-contracts.js.map +1 -0
- package/dist/acpx-engine/run-contracts.test.d.ts +2 -0
- package/dist/acpx-engine/run-contracts.test.d.ts.map +1 -0
- package/dist/acpx-engine/run-contracts.test.js +57 -0
- package/dist/acpx-engine/run-contracts.test.js.map +1 -0
- package/dist/acpx-engine/run-coordinator.d.ts +57 -0
- package/dist/acpx-engine/run-coordinator.d.ts.map +1 -0
- package/dist/acpx-engine/run-coordinator.js +63 -0
- package/dist/acpx-engine/run-coordinator.js.map +1 -0
- package/dist/acpx-engine/run-coordinator.test.d.ts +2 -0
- package/dist/acpx-engine/run-coordinator.test.d.ts.map +1 -0
- package/dist/acpx-engine/run-coordinator.test.js +160 -0
- package/dist/acpx-engine/run-coordinator.test.js.map +1 -0
- package/dist/acpx-engine/run-fault-matrix.test.d.ts +2 -0
- package/dist/acpx-engine/run-fault-matrix.test.d.ts.map +1 -0
- package/dist/acpx-engine/run-fault-matrix.test.js +768 -0
- package/dist/acpx-engine/run-fault-matrix.test.js.map +1 -0
- package/dist/acpx-engine/run-resource-ledger.d.ts +30 -0
- package/dist/acpx-engine/run-resource-ledger.d.ts.map +1 -0
- package/dist/acpx-engine/run-resource-ledger.js +148 -0
- package/dist/acpx-engine/run-resource-ledger.js.map +1 -0
- package/dist/acpx-engine/run-resource-ledger.test.d.ts +2 -0
- package/dist/acpx-engine/run-resource-ledger.test.d.ts.map +1 -0
- package/dist/acpx-engine/run-resource-ledger.test.js +154 -0
- package/dist/acpx-engine/run-resource-ledger.test.js.map +1 -0
- package/dist/acpx-engine/run-site-host.d.ts +71 -0
- package/dist/acpx-engine/run-site-host.d.ts.map +1 -0
- package/dist/acpx-engine/run-site-host.js +65 -0
- package/dist/acpx-engine/run-site-host.js.map +1 -0
- package/dist/acpx-engine/run-site-host.test.d.ts +2 -0
- package/dist/acpx-engine/run-site-host.test.d.ts.map +1 -0
- package/dist/acpx-engine/run-site-host.test.js +98 -0
- package/dist/acpx-engine/run-site-host.test.js.map +1 -0
- package/dist/acpx-engine/run-site-sandbox.d.ts +141 -0
- package/dist/acpx-engine/run-site-sandbox.d.ts.map +1 -0
- package/dist/acpx-engine/run-site-sandbox.js +278 -0
- package/dist/acpx-engine/run-site-sandbox.js.map +1 -0
- package/dist/acpx-engine/run-site-sandbox.test.d.ts +2 -0
- package/dist/acpx-engine/run-site-sandbox.test.d.ts.map +1 -0
- package/dist/acpx-engine/run-site-sandbox.test.js +271 -0
- package/dist/acpx-engine/run-site-sandbox.test.js.map +1 -0
- package/dist/acpx-engine/session-reuse-store.d.ts +62 -0
- package/dist/acpx-engine/session-reuse-store.d.ts.map +1 -0
- package/dist/acpx-engine/session-reuse-store.js +134 -0
- package/dist/acpx-engine/session-reuse-store.js.map +1 -0
- package/dist/acpx-engine/session-reuse-store.test.d.ts +2 -0
- package/dist/acpx-engine/session-reuse-store.test.d.ts.map +1 -0
- package/dist/acpx-engine/session-reuse-store.test.js +134 -0
- package/dist/acpx-engine/session-reuse-store.test.js.map +1 -0
- package/dist/acpx-engine/settlement-characterization.test.js +24 -27
- package/dist/acpx-engine/settlement-characterization.test.js.map +1 -1
- package/dist/acpx-engine/settlement-sequence.d.ts +77 -0
- package/dist/acpx-engine/settlement-sequence.d.ts.map +1 -0
- package/dist/acpx-engine/settlement-sequence.js +119 -0
- package/dist/acpx-engine/settlement-sequence.js.map +1 -0
- package/dist/acpx-engine/settlement-sequence.test.d.ts +2 -0
- package/dist/acpx-engine/settlement-sequence.test.d.ts.map +1 -0
- package/dist/acpx-engine/settlement-sequence.test.js +272 -0
- package/dist/acpx-engine/settlement-sequence.test.js.map +1 -0
- package/dist/acpx-engine/startup-characterization.test.js +21 -17
- package/dist/acpx-engine/startup-characterization.test.js.map +1 -1
- package/dist/acpx-engine/startup-timing.d.ts +36 -1
- package/dist/acpx-engine/startup-timing.d.ts.map +1 -1
- package/dist/acpx-engine/startup-timing.js +63 -1
- package/dist/acpx-engine/startup-timing.js.map +1 -1
- package/dist/acpx-engine/startup-timing.test.js +49 -1
- package/dist/acpx-engine/startup-timing.test.js.map +1 -1
- package/dist/acpx-engine/turn-sequence.d.ts +53 -0
- package/dist/acpx-engine/turn-sequence.d.ts.map +1 -0
- package/dist/acpx-engine/turn-sequence.js +55 -0
- package/dist/acpx-engine/turn-sequence.js.map +1 -0
- package/dist/acpx-engine/turn-sequence.test.d.ts +2 -0
- package/dist/acpx-engine/turn-sequence.test.d.ts.map +1 -0
- package/dist/acpx-engine/turn-sequence.test.js +177 -0
- package/dist/acpx-engine/turn-sequence.test.js.map +1 -0
- package/dist/command-managed-runtime.d.ts +53 -0
- package/dist/command-managed-runtime.d.ts.map +1 -1
- package/dist/command-managed-runtime.js +7 -0
- package/dist/command-managed-runtime.js.map +1 -1
- package/dist/command-managed-runtime.test.js +52 -0
- package/dist/command-managed-runtime.test.js.map +1 -1
- package/dist/duplex-aggregate-byte-ledger.d.ts +236 -0
- package/dist/duplex-aggregate-byte-ledger.d.ts.map +1 -0
- package/dist/duplex-aggregate-byte-ledger.js +302 -0
- package/dist/duplex-aggregate-byte-ledger.js.map +1 -0
- package/dist/duplex-aggregate-byte-ledger.test.d.ts +2 -0
- package/dist/duplex-aggregate-byte-ledger.test.d.ts.map +1 -0
- package/dist/duplex-aggregate-byte-ledger.test.js +173 -0
- package/dist/duplex-aggregate-byte-ledger.test.js.map +1 -0
- package/dist/duplex-body-spool.d.ts +175 -0
- package/dist/duplex-body-spool.d.ts.map +1 -0
- package/dist/duplex-body-spool.js +478 -0
- package/dist/duplex-body-spool.js.map +1 -0
- package/dist/duplex-body-spool.test.d.ts +2 -0
- package/dist/duplex-body-spool.test.d.ts.map +1 -0
- package/dist/duplex-body-spool.test.js +279 -0
- package/dist/duplex-body-spool.test.js.map +1 -0
- package/dist/duplex-bridge-broker-byte-ledger.test.d.ts +2 -0
- package/dist/duplex-bridge-broker-byte-ledger.test.d.ts.map +1 -0
- package/dist/duplex-bridge-broker-byte-ledger.test.js +299 -0
- package/dist/duplex-bridge-broker-byte-ledger.test.js.map +1 -0
- package/dist/duplex-bridge-broker.d.ts +286 -0
- package/dist/duplex-bridge-broker.d.ts.map +1 -0
- package/dist/duplex-bridge-broker.js +1098 -0
- package/dist/duplex-bridge-broker.js.map +1 -0
- package/dist/duplex-bridge-broker.test.d.ts +2 -0
- package/dist/duplex-bridge-broker.test.d.ts.map +1 -0
- package/dist/duplex-bridge-broker.test.js +869 -0
- package/dist/duplex-bridge-broker.test.js.map +1 -0
- package/dist/duplex-bridge-local-e2e.test.d.ts +2 -0
- package/dist/duplex-bridge-local-e2e.test.d.ts.map +1 -0
- package/dist/duplex-bridge-local-e2e.test.js +566 -0
- package/dist/duplex-bridge-local-e2e.test.js.map +1 -0
- package/dist/duplex-frame-codec.d.ts +267 -0
- package/dist/duplex-frame-codec.d.ts.map +1 -0
- package/dist/duplex-frame-codec.js +381 -0
- package/dist/duplex-frame-codec.js.map +1 -0
- package/dist/duplex-frame-codec.test.d.ts +2 -0
- package/dist/duplex-frame-codec.test.d.ts.map +1 -0
- package/dist/duplex-frame-codec.test.js +429 -0
- package/dist/duplex-frame-codec.test.js.map +1 -0
- package/dist/duplex-observability.d.ts +216 -0
- package/dist/duplex-observability.d.ts.map +1 -0
- package/dist/duplex-observability.js +234 -0
- package/dist/duplex-observability.js.map +1 -0
- package/dist/execution-target-sandbox.test.js +3585 -10
- package/dist/execution-target-sandbox.test.js.map +1 -1
- package/dist/execution-target.d.ts +170 -1
- package/dist/execution-target.d.ts.map +1 -1
- package/dist/execution-target.js +1039 -60
- package/dist/execution-target.js.map +1 -1
- package/dist/git-workspace-sync.d.ts +32 -0
- package/dist/git-workspace-sync.d.ts.map +1 -1
- package/dist/git-workspace-sync.js +84 -1
- package/dist/git-workspace-sync.js.map +1 -1
- package/dist/git-workspace-sync.test.js +104 -1
- package/dist/git-workspace-sync.test.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/login-capability.d.ts +88 -0
- package/dist/login-capability.d.ts.map +1 -0
- package/dist/login-capability.js +78 -0
- package/dist/login-capability.js.map +1 -0
- package/dist/login-capability.test.d.ts +2 -0
- package/dist/login-capability.test.d.ts.map +1 -0
- package/dist/login-capability.test.js +78 -0
- package/dist/login-capability.test.js.map +1 -0
- package/dist/{setup-token-transport.d.ts → login-pty-transport.d.ts} +6 -6
- package/dist/login-pty-transport.d.ts.map +1 -0
- package/dist/{setup-token-transport.js → login-pty-transport.js} +6 -6
- package/dist/login-pty-transport.js.map +1 -0
- package/dist/login-pty-transport.test.d.ts +2 -0
- package/dist/login-pty-transport.test.d.ts.map +1 -0
- package/dist/{setup-token-transport.test.js → login-pty-transport.test.js} +12 -12
- package/dist/login-pty-transport.test.js.map +1 -0
- package/dist/login-runner-lifecycle.d.ts +70 -0
- package/dist/login-runner-lifecycle.d.ts.map +1 -0
- package/dist/login-runner-lifecycle.js +52 -0
- package/dist/login-runner-lifecycle.js.map +1 -0
- package/dist/login-runner-lifecycle.test.d.ts +2 -0
- package/dist/login-runner-lifecycle.test.d.ts.map +1 -0
- package/dist/login-runner-lifecycle.test.js +122 -0
- package/dist/login-runner-lifecycle.test.js.map +1 -0
- package/dist/published-exports.test.d.ts +2 -0
- package/dist/published-exports.test.d.ts.map +1 -0
- package/dist/published-exports.test.js +17 -0
- package/dist/published-exports.test.js.map +1 -0
- package/dist/runtime-progress.d.ts.map +1 -1
- package/dist/runtime-progress.js +5 -1
- package/dist/runtime-progress.js.map +1 -1
- package/dist/runtime-progress.test.js +6 -6
- package/dist/runtime-progress.test.js.map +1 -1
- package/dist/sandbox-callback-bridge.d.ts +22 -0
- package/dist/sandbox-callback-bridge.d.ts.map +1 -1
- package/dist/sandbox-callback-bridge.js +944 -112
- package/dist/sandbox-callback-bridge.js.map +1 -1
- package/dist/sandbox-callback-bridge.test.js +285 -25
- package/dist/sandbox-callback-bridge.test.js.map +1 -1
- package/dist/sandbox-install-command.d.ts.map +1 -1
- package/dist/sandbox-install-command.js +5 -2
- package/dist/sandbox-install-command.js.map +1 -1
- package/dist/sandbox-install-command.test.js +4 -2
- package/dist/sandbox-install-command.test.js.map +1 -1
- package/dist/sandbox-managed-runtime.d.ts +17 -1
- package/dist/sandbox-managed-runtime.d.ts.map +1 -1
- package/dist/sandbox-managed-runtime.js +478 -330
- package/dist/sandbox-managed-runtime.js.map +1 -1
- package/dist/sandbox-managed-runtime.test.js +1125 -18
- package/dist/sandbox-managed-runtime.test.js.map +1 -1
- package/dist/server-utils.d.ts +1 -0
- package/dist/server-utils.d.ts.map +1 -1
- package/dist/server-utils.js +3 -3
- package/dist/server-utils.js.map +1 -1
- package/dist/server-utils.test.js +6 -1
- package/dist/server-utils.test.js.map +1 -1
- package/dist/session-compaction.d.ts.map +1 -1
- package/dist/session-compaction.js +6 -0
- package/dist/session-compaction.js.map +1 -1
- package/dist/ssh.d.ts.map +1 -1
- package/dist/ssh.js +34 -2
- package/dist/ssh.js.map +1 -1
- package/dist/sync-operation-schedule.d.ts +17 -0
- package/dist/sync-operation-schedule.d.ts.map +1 -0
- package/dist/sync-operation-schedule.js +66 -0
- package/dist/sync-operation-schedule.js.map +1 -0
- package/dist/sync-operation-schedule.test.d.ts +2 -0
- package/dist/sync-operation-schedule.test.d.ts.map +1 -0
- package/dist/sync-operation-schedule.test.js +130 -0
- package/dist/sync-operation-schedule.test.js.map +1 -0
- package/dist/types.d.ts +11 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -2
- package/dist/setup-token-transport.d.ts.map +0 -1
- package/dist/setup-token-transport.js.map +0 -1
- package/dist/setup-token-transport.test.d.ts +0 -2
- package/dist/setup-token-transport.test.d.ts.map +0 -1
- package/dist/setup-token-transport.test.js.map +0 -1
|
@@ -6,12 +6,19 @@ import { execFile } from "node:child_process";
|
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { createHash, randomUUID } from "node:crypto";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { adapterExecutionTargetSessionIdentity, describeAdapterExecutionTarget, formatAdapterExecutionTimeoutErrorMessage, formatAdapterExecutionTimeoutStartLogLine, prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, resolveAdapterExecutionTargetTimeout, runAdapterExecutionTargetShellCommand, startAdapterExecutionTargetPaperclipBridge, startAdapterExecutionTargetProcessSessionBridge, } from "@paperclipai/adapter-utils/execution-target";
|
|
9
|
+
import { adapterExecutionTargetSessionIdentity, describeAdapterExecutionTarget, adapterExecutionTargetDuplexObservabilityRecorder, adapterExecutionTargetEnablesSandboxDuplexBridge, formatAdapterExecutionTimeoutErrorMessage, formatAdapterExecutionTimeoutStartLogLine, prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, resolveAdapterExecutionTargetTimeout, runAdapterExecutionTargetShellCommand, startAdapterExecutionTargetPaperclipBridge, startAdapterExecutionTargetProcessSessionBridge, } from "@paperclipai/adapter-utils/execution-target";
|
|
10
|
+
import { DUPLEX_CHANNEL_LOST_ERROR_CODE } from "../duplex-bridge-broker.js";
|
|
10
11
|
import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, applyPaperclipWorkspaceEnv, asNumber, asString, buildInvocationEnvForLogs, buildPaperclipEnv, ensureAbsoluteDirectory, ensurePathInEnv, ensurePaperclipSkillSymlink, isForbiddenConfigEnvKey, isPaperclipRuntimeEnvKey, joinPromptSections, materializePaperclipSkillCopy, parseObject, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, renderPaperclipWakePrompt, renderTemplate, resolvePaperclipInstanceRootForAdapter, selectPaperclipTaskMarkdown, resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, rewriteWorkspaceCwdEnvVarsForExecution, shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, } from "@paperclipai/adapter-utils/server-utils";
|
|
11
12
|
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
|
|
12
13
|
import { createAcpRuntime, createAgentRegistry, createRuntimeStore, isAcpRuntimeError, } from "acpx/runtime";
|
|
13
14
|
import { DEFAULT_ACP_ENGINE_AGENT, DEFAULT_ACP_ENGINE_MODE, DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, DEFAULT_ACP_ENGINE_PERMISSION_MODE, DEFAULT_ACP_ENGINE_TIMEOUT_SEC, DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, } from "./constants.js";
|
|
14
|
-
import {
|
|
15
|
+
import { createRunResourceLedger } from "./run-resource-ledger.js";
|
|
16
|
+
import { settleAcpRun } from "./settlement-sequence.js";
|
|
17
|
+
import { runAttempt, } from "./run-coordinator.js";
|
|
18
|
+
import { runTurn as runTurnSequence, } from "./turn-sequence.js";
|
|
19
|
+
import { createHostRunSite, } from "./run-site-host.js";
|
|
20
|
+
import { createSandboxRunSite } from "./run-site-sandbox.js";
|
|
21
|
+
import { createRuntimeSpanRunner, emitRunPhaseTiming, emitSkippedStartupStep, getActiveStepContext, measureStartupStep, NOOP_STARTUP_SPAN, NOOP_STARTUP_TRACE_CONTEXT, runWithRuntimeParent, setSandboxRootSpanAttributes, } from "./startup-timing.js";
|
|
15
22
|
const defaultModuleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
16
23
|
const PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST = ".paperclip-managed-skills.json";
|
|
17
24
|
const BENIGN_NES_CLOSE_STDERR = /method: ['"]nes\/close['"].*-32601/;
|
|
@@ -66,6 +73,40 @@ function stableJson(value) {
|
|
|
66
73
|
function shortHash(value) {
|
|
67
74
|
return createHash("sha256").update(stableJson(value)).digest("hex").slice(0, 16);
|
|
68
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Hash the session fingerprint. The builder accepts only a
|
|
78
|
+
* `SessionFingerprintIdentity`, so no company, agent, or task identifier can
|
|
79
|
+
* enter the hash. Those identifiers scope the outer session key only (see
|
|
80
|
+
* `SessionKeyIdentity`).
|
|
81
|
+
*/
|
|
82
|
+
export function buildSessionFingerprint(identity) {
|
|
83
|
+
return shortHash(identity);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build the session key from the fingerprint and the outer-key identity. The key
|
|
87
|
+
* form is `paperclip:companyId:agentId:taskKey:fingerprint`.
|
|
88
|
+
*/
|
|
89
|
+
export function buildSessionKey(identity, fingerprint) {
|
|
90
|
+
return `paperclip:${identity.companyId}:${identity.agentId}:${identity.taskKey}:${fingerprint}`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Build the single branded launch environment for a run. This is the sole
|
|
94
|
+
* constructor of `LaunchEnvironment`. It applies each contribution into the base
|
|
95
|
+
* env in registration order, then resolves and freezes the launch env.
|
|
96
|
+
*
|
|
97
|
+
* A contribution carries its credential scope. A run-scoped contribution (the
|
|
98
|
+
* run API key, a bridge token) and a session-scoped contribution (the Codex auth
|
|
99
|
+
* copy-back material) both merge here. Neither scope can escape into a reuse
|
|
100
|
+
* payload, because the branded contribution type and the branded environment
|
|
101
|
+
* type forbid it.
|
|
102
|
+
*/
|
|
103
|
+
export function finalizeLaunchEnvironment(baseEnv, contributions) {
|
|
104
|
+
for (const contribution of contributions) {
|
|
105
|
+
Object.assign(baseEnv, contribution.env);
|
|
106
|
+
}
|
|
107
|
+
const env = Object.freeze(resolveRuntimeEnv(baseEnv));
|
|
108
|
+
return { env };
|
|
109
|
+
}
|
|
69
110
|
// Directory names the staging path never ships for a referenced project (heavy
|
|
70
111
|
// build/cache output and git history). The content signature skips them so it
|
|
71
112
|
// reflects only the staged tree and never reads their bytes. Keep this set equal
|
|
@@ -180,6 +221,11 @@ async function resolveBuiltInAgentCommand(input) {
|
|
|
180
221
|
if (agent === "gemini") {
|
|
181
222
|
return { command: "gemini --acp", shellCommand: "gemini --acp" };
|
|
182
223
|
}
|
|
224
|
+
if (agent === "kimi") {
|
|
225
|
+
// Kimi Code exposes its ACP server via the `kimi acp` subcommand (stdio),
|
|
226
|
+
// rather than a flag (gemini) or a dedicated bin (claude/codex).
|
|
227
|
+
return { command: "kimi acp", shellCommand: "kimi acp" };
|
|
228
|
+
}
|
|
183
229
|
const binName = agent === "claude" ? "claude-agent-acp" : agent === "codex" ? "codex-acp" : null;
|
|
184
230
|
if (!binName)
|
|
185
231
|
return null;
|
|
@@ -913,6 +959,9 @@ async function buildRuntime(input) {
|
|
|
913
959
|
// here on the awaited directory materialization.
|
|
914
960
|
await measureStartupStep(input.ctx, nowMs, "workspace.resolve", () => ensureAbsoluteDirectory(cwd, { createIfMissing: true }), stepMetrics);
|
|
915
961
|
const acpxAgent = normalizeAgent(config);
|
|
962
|
+
// Run summaries always fail closed to the final output segment so internal
|
|
963
|
+
// thought text and intermediate narration cannot become issue comments.
|
|
964
|
+
const coalescePlaceholderToolUpdates = config.coalescePlaceholderToolUpdates === true;
|
|
916
965
|
const mode = normalizeMode(config);
|
|
917
966
|
const permissionMode = normalizePermissionMode(config);
|
|
918
967
|
const nonInteractivePermissions = normalizeNonInteractivePermissions(config);
|
|
@@ -1144,7 +1193,10 @@ async function buildRuntime(input) {
|
|
|
1144
1193
|
const sessionCwd = useRemoteProcessSession && executionTarget?.kind === "remote"
|
|
1145
1194
|
? executionTarget.remoteCwd
|
|
1146
1195
|
: cwd;
|
|
1147
|
-
|
|
1196
|
+
// The 17 fields the session fingerprint hashes. Company, agent, and task
|
|
1197
|
+
// identifiers are NOT here; they scope the outer session key only (see
|
|
1198
|
+
// `keyIdentity`). The fingerprint builder accepts only this identity.
|
|
1199
|
+
const fingerprintIdentity = {
|
|
1148
1200
|
acpxAgent,
|
|
1149
1201
|
agentCommand: agentCommand ?? acpxAgent,
|
|
1150
1202
|
cwd: path.resolve(sessionCwd),
|
|
@@ -1159,7 +1211,7 @@ async function buildRuntime(input) {
|
|
|
1159
1211
|
// added, removed, or re-pinned) invalidates a warm/resumable session so the
|
|
1160
1212
|
// next launch stages the current referenced-project trees instead of reusing
|
|
1161
1213
|
// a stale staged tree.
|
|
1162
|
-
additionalSourcesIdentity,
|
|
1214
|
+
additionalSourcesIdentity: additionalSourcesIdentity,
|
|
1163
1215
|
skillsIdentity,
|
|
1164
1216
|
skillPromptInstructions,
|
|
1165
1217
|
paperclipClaudeSettings: paperclipClaudeSettings
|
|
@@ -1179,9 +1231,17 @@ async function buildRuntime(input) {
|
|
|
1179
1231
|
// edits and same-version secret rotations. Per-wake runtime vars never enter
|
|
1180
1232
|
// resolvedAdapterEnv, so they don't churn the fingerprint every heartbeat.
|
|
1181
1233
|
adapterEnvHash: shortHash(resolvedAdapterEnv),
|
|
1182
|
-
}
|
|
1234
|
+
};
|
|
1235
|
+
const fingerprint = buildSessionFingerprint(fingerprintIdentity);
|
|
1183
1236
|
const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default";
|
|
1184
|
-
|
|
1237
|
+
// Company, agent, and task identity for the outer session key. These parts
|
|
1238
|
+
// stay out of the fingerprint hash.
|
|
1239
|
+
const keyIdentity = {
|
|
1240
|
+
companyId: agent.companyId,
|
|
1241
|
+
agentId: agent.id,
|
|
1242
|
+
taskKey,
|
|
1243
|
+
};
|
|
1244
|
+
const sessionKey = buildSessionKey(keyIdentity, fingerprint);
|
|
1185
1245
|
// Ship the workspace into the sandbox and capture `{ workspaceRemoteDir,
|
|
1186
1246
|
// runtimeRootDir, assetDirs, restoreWorkspace }`. Done once here, before the
|
|
1187
1247
|
// bridges, so both bridges receive the real (non-null) `runtimeRootDir`.
|
|
@@ -1232,244 +1292,174 @@ async function buildRuntime(input) {
|
|
|
1232
1292
|
let remoteStagingDispose = null;
|
|
1233
1293
|
let remoteStagingEnvDelta = null;
|
|
1234
1294
|
let sessionStagingLeaseRelease = null;
|
|
1295
|
+
// The sandbox run site owns the sandbox lane's staging, the per-session
|
|
1296
|
+
// staging lease, both host-side bridges, the launch-environment contribution,
|
|
1297
|
+
// and sync-back. `buildRuntime` injects the leaf primitives (the workspace
|
|
1298
|
+
// stage call, the managed-home seam, the two bridge starts, the step timers,
|
|
1299
|
+
// and the launch-env finalizer) and reads the site's results back onto the
|
|
1300
|
+
// prepared-runtime fields, so the existing settlement path stays unchanged.
|
|
1301
|
+
let sandboxSite = null;
|
|
1235
1302
|
if (useRemoteProcessSession && executionTarget?.kind === "remote") {
|
|
1236
1303
|
const remoteTarget = executionTarget;
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
// the engine disposes the fresh staged runtime here (see the catch below).
|
|
1277
|
-
let freshlyStagedRuntime = null;
|
|
1278
|
-
const stage = async (assets) => {
|
|
1279
|
-
const staged = await stageAcpRemoteRuntime({
|
|
1280
|
-
runId,
|
|
1281
|
-
target: remoteTarget,
|
|
1282
|
-
adapterKey: input.engine.adapterType,
|
|
1283
|
-
workspaceLocalDir: cwd,
|
|
1284
|
-
workspaceRemoteDir: sessionCwd,
|
|
1285
|
-
timeoutSec,
|
|
1286
|
-
assets,
|
|
1287
|
-
additionalSources,
|
|
1288
|
-
onLog: input.ctx.onLog,
|
|
1289
|
-
onRuntimeProgress: input.ctx.onRuntimeProgress,
|
|
1290
|
-
runtimeSpan: input.stageRuntimeSpan,
|
|
1291
|
-
});
|
|
1292
|
-
freshlyStagedRuntime = staged;
|
|
1293
|
-
return staged;
|
|
1294
|
-
};
|
|
1295
|
-
// Snapshot env before the seam so we can capture exactly which keys it
|
|
1296
|
-
// repointed onto the in-sandbox home (e.g. `CODEX_HOME`) and replay them
|
|
1297
|
-
// verbatim on a later compatible resume. Add/change only — every seam sets
|
|
1298
|
-
// (never deletes) its home env var, so a set-based delta is complete.
|
|
1299
|
-
const envBeforeStage = { ...env };
|
|
1300
|
-
// Step 4 — stage.sync: ship the workspace (and, via the seam, the managed
|
|
1301
|
-
// home) into the sandbox. Only fires on a fresh stage; a compatible resume
|
|
1302
|
-
// that reuses an already-staged runtime skips this block entirely. The
|
|
1303
|
-
// measured callback returns the staged result so the timing wrap does not
|
|
1304
|
-
// disturb definite-assignment of the outer bindings.
|
|
1305
|
-
const { stagedRuntime: freshStagedRuntime, teardown: freshTeardown, dispose: freshDispose, } = await measureStartupStep(input.ctx, nowMs, "stage.sync", async () => {
|
|
1306
|
-
if (input.deps.prepareRemoteManagedHome) {
|
|
1307
|
-
let seeded;
|
|
1308
|
-
try {
|
|
1309
|
-
seeded = await input.deps.prepareRemoteManagedHome({
|
|
1310
|
-
acpxAgent,
|
|
1311
|
-
companyId: agent.companyId,
|
|
1312
|
-
runId,
|
|
1313
|
-
config,
|
|
1314
|
-
executionTarget: remoteTarget,
|
|
1315
|
-
workspaceLocalDir: cwd,
|
|
1316
|
-
timeoutSec,
|
|
1317
|
-
env,
|
|
1318
|
-
onLog: input.ctx.onLog,
|
|
1319
|
-
onRuntimeProgress: input.ctx.onRuntimeProgress,
|
|
1320
|
-
stage,
|
|
1321
|
-
});
|
|
1322
|
-
}
|
|
1323
|
-
catch (seamErr) {
|
|
1324
|
-
// The seam failed after a possible successful stage. Dispose the fresh
|
|
1325
|
-
// staged runtime so the abandoned in-sandbox managed home does not leak,
|
|
1326
|
-
// then rethrow the seam error.
|
|
1327
|
-
if (freshlyStagedRuntime) {
|
|
1328
|
-
await disposeFreshStagedRuntime({
|
|
1329
|
-
runId,
|
|
1330
|
-
target: remoteTarget,
|
|
1331
|
-
stagedRuntime: freshlyStagedRuntime,
|
|
1332
|
-
cwd: sessionCwd,
|
|
1333
|
-
timeoutSec,
|
|
1334
|
-
onLog: input.ctx.onLog,
|
|
1335
|
-
});
|
|
1336
|
-
}
|
|
1337
|
-
throw seamErr;
|
|
1338
|
-
}
|
|
1304
|
+
sandboxSite = createSandboxRunSite({
|
|
1305
|
+
ledger: input.ledger,
|
|
1306
|
+
stagedRuntimes,
|
|
1307
|
+
stagingLocks,
|
|
1308
|
+
now: nowMs,
|
|
1309
|
+
idleMs: input.stagedIdleMs,
|
|
1310
|
+
sessionCwd,
|
|
1311
|
+
spawnCwd: cwd,
|
|
1312
|
+
target: remoteTarget,
|
|
1313
|
+
env,
|
|
1314
|
+
isCompatibleResume,
|
|
1315
|
+
stage: (assets) => stageAcpRemoteRuntime({
|
|
1316
|
+
runId,
|
|
1317
|
+
target: remoteTarget,
|
|
1318
|
+
adapterKey: input.engine.adapterType,
|
|
1319
|
+
workspaceLocalDir: cwd,
|
|
1320
|
+
workspaceRemoteDir: sessionCwd,
|
|
1321
|
+
timeoutSec,
|
|
1322
|
+
assets,
|
|
1323
|
+
additionalSources,
|
|
1324
|
+
onLog: input.ctx.onLog,
|
|
1325
|
+
onRuntimeProgress: input.ctx.onRuntimeProgress,
|
|
1326
|
+
runtimeSpan: input.stageRuntimeSpan,
|
|
1327
|
+
}),
|
|
1328
|
+
seedManagedHome: input.deps.prepareRemoteManagedHome
|
|
1329
|
+
? async (stage) => {
|
|
1330
|
+
const seeded = await input.deps.prepareRemoteManagedHome({
|
|
1331
|
+
acpxAgent,
|
|
1332
|
+
companyId: agent.companyId,
|
|
1333
|
+
runId,
|
|
1334
|
+
config,
|
|
1335
|
+
executionTarget: remoteTarget,
|
|
1336
|
+
workspaceLocalDir: cwd,
|
|
1337
|
+
timeoutSec,
|
|
1338
|
+
env,
|
|
1339
|
+
onLog: input.ctx.onLog,
|
|
1340
|
+
onRuntimeProgress: input.ctx.onRuntimeProgress,
|
|
1341
|
+
stage,
|
|
1342
|
+
});
|
|
1339
1343
|
return {
|
|
1340
1344
|
stagedRuntime: seeded.stagedRuntime,
|
|
1341
1345
|
teardown: seeded.teardown ?? null,
|
|
1342
1346
|
dispose: seeded.disposeStaged ?? null,
|
|
1343
1347
|
};
|
|
1344
1348
|
}
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
if (envBeforeStage[key] !== value)
|
|
1350
|
-
delta[key] = value;
|
|
1351
|
-
}
|
|
1352
|
-
return {
|
|
1349
|
+
: undefined,
|
|
1350
|
+
disposeFreshStagedRuntime: (freshStagedRuntime) => disposeFreshStagedRuntime({
|
|
1351
|
+
runId,
|
|
1352
|
+
target: remoteTarget,
|
|
1353
1353
|
stagedRuntime: freshStagedRuntime,
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
workspaceCwd: effectiveWorkspaceCwd,
|
|
1375
|
-
workspaceWorktreePath,
|
|
1376
|
-
workspaceHints,
|
|
1377
|
-
executionTargetIsRemote,
|
|
1378
|
-
executionCwd: effectiveExecutionCwd,
|
|
1379
|
-
stagedProjectDirs,
|
|
1380
|
-
}).workspaceHints;
|
|
1381
|
-
if (shapedHints.length > 0) {
|
|
1382
|
-
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedHints);
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
// Both bridge starts run under one try so a failure at EITHER — including the
|
|
1387
|
-
// paperclip callback bridge — fires the same abandon-path cleanup. The
|
|
1388
|
-
// paperclip bridge starts after the workspace + managed home were already
|
|
1389
|
-
// staged and the per-session staging lease is already held, so leaving it
|
|
1390
|
-
// outside the catch would strand the lease (and the staged temp) on a
|
|
1391
|
-
// start failure and deadlock the next run of this session.
|
|
1392
|
-
let paperclipBridge = null;
|
|
1393
|
-
let processSessionBridge = null;
|
|
1394
|
-
let runtimeEnv = {};
|
|
1395
|
-
try {
|
|
1396
|
-
if (useRemoteProcessSession) {
|
|
1397
|
-
// Steps 5 + 6 — bring up BOTH host-side sandbox bridges concurrently. Their
|
|
1398
|
-
// remote subtrees are disjoint (`…/paperclip-bridge/…` vs
|
|
1399
|
-
// `…/process-sessions/…`), so the env-INDEPENDENT setup of each overlaps,
|
|
1400
|
-
// trending wall time from serial (~bridge.paperclip + ~bridge.process-session)
|
|
1401
|
-
// toward ~max(the two). The ONE real dependency — the paperclip bridge's
|
|
1402
|
-
// returned `env` must reach the process-session LAUNCH — is sequenced by
|
|
1403
|
-
// `finalizeLaunchEnv`: the process-session bridge runs its env-independent
|
|
1404
|
-
// dir/script setup first, then awaits that thunk right before its launch, so
|
|
1405
|
-
// the launch always observes the merged paperclip env.
|
|
1406
|
-
//
|
|
1407
|
-
// Both `run.startup.step` events still emit — `measureStartupStep` records
|
|
1408
|
-
// them in a `finally`, even on a start failure.
|
|
1409
|
-
const paperclipStart = measureStartupStep(input.ctx, nowMs, "bridge.paperclip", () => startAdapterExecutionTargetPaperclipBridge({
|
|
1354
|
+
cwd: sessionCwd,
|
|
1355
|
+
timeoutSec,
|
|
1356
|
+
onLog: input.ctx.onLog,
|
|
1357
|
+
}),
|
|
1358
|
+
measureStageStep: (run) => measureStartupStep(input.ctx, nowMs, "stage.sync", run, stepMetrics),
|
|
1359
|
+
publishStagedProjectHints: (stagedProjectDirs) => {
|
|
1360
|
+
const shapedHints = shapePaperclipWorkspaceEnvForExecution({
|
|
1361
|
+
workspaceCwd: effectiveWorkspaceCwd,
|
|
1362
|
+
workspaceWorktreePath,
|
|
1363
|
+
workspaceHints,
|
|
1364
|
+
executionTargetIsRemote,
|
|
1365
|
+
executionCwd: effectiveExecutionCwd,
|
|
1366
|
+
stagedProjectDirs,
|
|
1367
|
+
}).workspaceHints;
|
|
1368
|
+
if (shapedHints.length > 0) {
|
|
1369
|
+
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedHints);
|
|
1370
|
+
}
|
|
1371
|
+
},
|
|
1372
|
+
onReuseLog: () => input.ctx.onLog("stdout", "[paperclip] Reusing the staged in-sandbox runtime for this resumed session (no workspace re-ship / managed-home re-seed).\n"),
|
|
1373
|
+
startPaperclipBridge: (runtimeRootDir) => startAdapterExecutionTargetPaperclipBridge({
|
|
1410
1374
|
runId,
|
|
1411
|
-
target: { ...
|
|
1412
|
-
runtimeRootDir
|
|
1375
|
+
target: { ...remoteTarget, streamRunLogs: false },
|
|
1376
|
+
runtimeRootDir,
|
|
1413
1377
|
adapterKey: input.engine.adapterType,
|
|
1414
1378
|
timeoutSec,
|
|
1415
1379
|
hostApiToken: env.PAPERCLIP_API_KEY,
|
|
1380
|
+
enableSandboxDuplexBridge: adapterExecutionTargetEnablesSandboxDuplexBridge(remoteTarget),
|
|
1381
|
+
duplexObservabilityRecorder: adapterExecutionTargetDuplexObservabilityRecorder(remoteTarget),
|
|
1416
1382
|
onLog: input.ctx.onLog,
|
|
1417
1383
|
getRuntimeParentContext: input.getRuntimeParentContext,
|
|
1418
1384
|
runtimeSpan: input.runtimeSpan,
|
|
1419
|
-
}),
|
|
1420
|
-
|
|
1421
|
-
// Memoized so the merge + log + `runtimeEnv` build run EXACTLY once whether
|
|
1422
|
-
// the process-session bridge consumes it at launch or we finalize it below.
|
|
1423
|
-
let launchEnvPromise = null;
|
|
1424
|
-
const finalizeLaunchEnv = () => (launchEnvPromise ??= (async () => {
|
|
1425
|
-
const paperclip = await paperclipStart;
|
|
1426
|
-
if (paperclip) {
|
|
1427
|
-
Object.assign(env, paperclip.env);
|
|
1428
|
-
await input.ctx.onLog("stdout", "[paperclip] Sandbox ACP API callback bridge enabled for this run.\n");
|
|
1429
|
-
}
|
|
1430
|
-
return (runtimeEnv = resolveRuntimeEnv(env));
|
|
1431
|
-
})());
|
|
1432
|
-
const processSessionStart = measureStartupStep(input.ctx, nowMs, "bridge.process-session", () => startAdapterExecutionTargetProcessSessionBridge({
|
|
1385
|
+
}),
|
|
1386
|
+
startProcessSessionBridge: ({ runtimeRootDir, launchEnv }) => startAdapterExecutionTargetProcessSessionBridge({
|
|
1433
1387
|
runId,
|
|
1434
|
-
target:
|
|
1435
|
-
runtimeRootDir
|
|
1388
|
+
target: remoteTarget,
|
|
1389
|
+
runtimeRootDir,
|
|
1436
1390
|
adapterKey: input.engine.adapterType,
|
|
1437
1391
|
command: "sh",
|
|
1438
1392
|
args: ["-lc", `exec ${agentCommandShell}`],
|
|
1439
1393
|
cwd: sessionCwd,
|
|
1440
|
-
|
|
1441
|
-
// then calls this to get the launch env AFTER the paperclip env merge.
|
|
1442
|
-
env: finalizeLaunchEnv,
|
|
1394
|
+
env: launchEnv,
|
|
1443
1395
|
timeoutSec,
|
|
1444
1396
|
onLog: input.ctx.onLog,
|
|
1445
1397
|
getRuntimeParentContext: input.getRuntimeParentContext,
|
|
1446
1398
|
runtimeSpan: input.runtimeSpan,
|
|
1447
1399
|
streamOutputViaSession: streamAgentSessionOutput,
|
|
1448
|
-
}),
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1400
|
+
}),
|
|
1401
|
+
measureBridgeStep: (step, run) => measureStartupStep(input.ctx, nowMs, step, run, concurrentBridgeStepMetrics),
|
|
1402
|
+
finalizeLaunchEnv: (contributions) => finalizeLaunchEnvironment(env, contributions).env,
|
|
1403
|
+
onPaperclipBridgeLog: () => input.ctx.onLog("stdout", "[paperclip] Sandbox ACP API callback bridge enabled for this run.\n"),
|
|
1404
|
+
stopBridges: async ({ controlBridge, agentBridge }) => {
|
|
1405
|
+
await Promise.allSettled([agentBridge?.stop(), controlBridge?.stop()]);
|
|
1406
|
+
if (remoteManagedHomeTeardown) {
|
|
1407
|
+
await remoteManagedHomeTeardown().catch(() => { });
|
|
1408
|
+
}
|
|
1409
|
+
},
|
|
1410
|
+
});
|
|
1411
|
+
// Place the workspace (stage fresh or reuse the already-staged runtime) under
|
|
1412
|
+
// the per-session staging lease, then read the staged result back onto the
|
|
1413
|
+
// prepared-runtime fields the settlement path consumes.
|
|
1414
|
+
const placeWorkspaceStart = nowMs();
|
|
1415
|
+
await sandboxSite.placeWorkspace({ sessionKey });
|
|
1416
|
+
await emitRunPhaseTiming(input.ctx, "place_workspace", nowMs() - placeWorkspaceStart, "ok");
|
|
1417
|
+
const placedStaged = sandboxSite.staged;
|
|
1418
|
+
stagedRuntime = placedStaged?.stagedRuntime ?? null;
|
|
1419
|
+
remoteManagedHomeTeardown = placedStaged?.teardown ?? null;
|
|
1420
|
+
remoteStagingDispose = placedStaged?.dispose ?? null;
|
|
1421
|
+
remoteStagingEnvDelta = placedStaged?.envDelta ?? null;
|
|
1422
|
+
sessionStagingLeaseRelease = sandboxSite.stagingLeaseRelease;
|
|
1423
|
+
}
|
|
1424
|
+
// Both bridge starts run under one try so a failure at EITHER — including the
|
|
1425
|
+
// paperclip callback bridge — fires the same abandon-path cleanup. The
|
|
1426
|
+
// paperclip bridge starts after the workspace + managed home were already
|
|
1427
|
+
// staged and the per-session staging lease is already held, so leaving it
|
|
1428
|
+
// outside the catch would strand the lease (and the staged temp) on a
|
|
1429
|
+
// start failure and deadlock the next run of this session.
|
|
1430
|
+
let paperclipBridge = null;
|
|
1431
|
+
let processSessionBridge = null;
|
|
1432
|
+
let runtimeEnv = {};
|
|
1433
|
+
const startTransportStart = nowMs();
|
|
1434
|
+
try {
|
|
1435
|
+
if (useRemoteProcessSession && sandboxSite) {
|
|
1436
|
+
// The sandbox run site brings up both host-side bridges concurrently, keeps
|
|
1437
|
+
// the one paperclip-env → process-session-launch dependency at a single
|
|
1438
|
+
// sequencing point, settles both starts, and returns the started handles
|
|
1439
|
+
// plus the finalized launch env. On a partial failure it stops nothing and
|
|
1440
|
+
// rethrows; the catch below stops whichever bridge the site started.
|
|
1441
|
+
const transport = await sandboxSite.startTransport({ sessionKey });
|
|
1442
|
+
paperclipBridge = transport.controlBridge;
|
|
1443
|
+
processSessionBridge = transport.agentBridge;
|
|
1444
|
+
runtimeEnv = transport.launchEnv;
|
|
1445
|
+
await emitRunPhaseTiming(input.ctx, "start_transport", nowMs() - startTransportStart, "ok");
|
|
1460
1446
|
}
|
|
1461
1447
|
else {
|
|
1462
|
-
// Local / runner-less lanes never start a bridge,
|
|
1463
|
-
//
|
|
1464
|
-
|
|
1448
|
+
// Local / runner-less lanes never start a bridge, so they add no
|
|
1449
|
+
// contribution. `finalizeLaunchEnvironment` still produces the one branded
|
|
1450
|
+
// launch env the prepared runtime and the log builder read.
|
|
1451
|
+
runtimeEnv = finalizeLaunchEnvironment(env, []).env;
|
|
1465
1452
|
}
|
|
1466
1453
|
}
|
|
1467
1454
|
catch (err) {
|
|
1468
1455
|
// On a partial concurrent bring-up failure, ONE bridge may have started while
|
|
1469
1456
|
// the other threw; `Promise.allSettled` stops whichever started so no live
|
|
1470
|
-
// bridge leaks (mirrors `
|
|
1471
|
-
//
|
|
1472
|
-
|
|
1457
|
+
// bridge leaks (mirrors the settlement `stopTransport` step). The site sets its
|
|
1458
|
+
// started bridges before it rethrows, so read them from the site here (the
|
|
1459
|
+
// local handles stay null when `startTransport` throws before it returns).
|
|
1460
|
+
const startedControl = sandboxSite?.controlBridge ?? paperclipBridge;
|
|
1461
|
+
const startedAgent = sandboxSite?.agentBridge ?? processSessionBridge;
|
|
1462
|
+
await Promise.allSettled([startedControl?.stop(), startedAgent?.stop()]);
|
|
1473
1463
|
// The staged home / copy-back teardown must run even if a bridge fails to
|
|
1474
1464
|
// start after the workspace + managed home were already staged into the
|
|
1475
1465
|
// sandbox, so a refreshed credential is copied back on this error path too.
|
|
@@ -1477,7 +1467,7 @@ async function buildRuntime(input) {
|
|
|
1477
1467
|
// dispose here (it no longer rides the per-run copy-back) — the run is being
|
|
1478
1468
|
// abandoned, so its staged temp must be released — and release the per-session
|
|
1479
1469
|
// staging lease so the abandoned run does not strand the next same-session run
|
|
1480
|
-
// (
|
|
1470
|
+
// (the run root `finally`, which normally releases it, is never reached here).
|
|
1481
1471
|
//
|
|
1482
1472
|
// Route the dispose through the same ownership-guarded removal
|
|
1483
1473
|
// `discardStagedRuntime` uses. When this run BORROWED the cached staged runtime
|
|
@@ -1494,6 +1484,7 @@ async function buildRuntime(input) {
|
|
|
1494
1484
|
dispose: remoteStagingDispose,
|
|
1495
1485
|
});
|
|
1496
1486
|
sessionStagingLeaseRelease?.();
|
|
1487
|
+
await emitRunPhaseTiming(input.ctx, "start_transport", nowMs() - startTransportStart, "failed");
|
|
1497
1488
|
throw err;
|
|
1498
1489
|
}
|
|
1499
1490
|
const overrideCommand = processSessionBridge?.agentCommand ?? agentCommand;
|
|
@@ -1506,6 +1497,7 @@ async function buildRuntime(input) {
|
|
|
1506
1497
|
});
|
|
1507
1498
|
return {
|
|
1508
1499
|
acpxAgent,
|
|
1500
|
+
coalescePlaceholderToolUpdates,
|
|
1509
1501
|
mode,
|
|
1510
1502
|
// Remote runner-backed → the in-sandbox workspace dir; local / runner-less
|
|
1511
1503
|
// → the HOST cwd (`sessionCwd` resolves both). Every cwd-keyed session site
|
|
@@ -1601,52 +1593,29 @@ async function applySessionConfigOptions(input) {
|
|
|
1601
1593
|
function resolveRuntimeEnv(env) {
|
|
1602
1594
|
return Object.fromEntries(Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter((entry) => typeof entry[1] === "string"));
|
|
1603
1595
|
}
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
* (paperclip before process-session) for the caller to rethrow through the
|
|
1612
|
-
* shared abandon path.
|
|
1613
|
-
*/
|
|
1614
|
-
async function settleRemoteBridgeStarts(paperclipStart, processSessionStart) {
|
|
1615
|
-
const [paperclip, processSession] = await Promise.allSettled([
|
|
1616
|
-
paperclipStart,
|
|
1617
|
-
processSessionStart,
|
|
1596
|
+
// Stop both host-side bridges in one `allSettled`. This is the settlement
|
|
1597
|
+
// `stopTransport` effect. The bridge tokens are run-scoped, so they die with the
|
|
1598
|
+
// bridges here (Amendment B).
|
|
1599
|
+
async function stopRunTransport(prepared) {
|
|
1600
|
+
await Promise.allSettled([
|
|
1601
|
+
prepared.processSessionBridge?.stop(),
|
|
1602
|
+
prepared.paperclipBridge?.stop(),
|
|
1618
1603
|
]);
|
|
1619
|
-
return {
|
|
1620
|
-
paperclipBridge: paperclip.status === "fulfilled" ? paperclip.value : null,
|
|
1621
|
-
processSessionBridge: processSession.status === "fulfilled" ? processSession.value : null,
|
|
1622
|
-
failure: paperclip.status === "rejected"
|
|
1623
|
-
? paperclip.reason
|
|
1624
|
-
: processSession.status === "rejected"
|
|
1625
|
-
? processSession.reason
|
|
1626
|
-
: null,
|
|
1627
|
-
};
|
|
1628
1604
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
}
|
|
1644
|
-
}
|
|
1645
|
-
finally {
|
|
1646
|
-
// Release the per-session staging lease in a finally, so an earlier teardown
|
|
1647
|
-
// fault never strands it and deadlocks the next run of this session.
|
|
1648
|
-
prepared.sessionStagingLeaseRelease?.();
|
|
1649
|
-
}
|
|
1605
|
+
// The site sync-back. This is the settlement `syncBack` effect. It runs AFTER the
|
|
1606
|
+
// bridges stop (mirrors the CLI finally: stop bridge → restore workspace). It
|
|
1607
|
+
// fires the codex auth copy-back via `restoreWorkspace()` and removes staged temp
|
|
1608
|
+
// dirs. The seam logs and swallows its own failures — an unclean-teardown
|
|
1609
|
+
// copy-back miss is the accepted, loud `refresh_token_reused` residual on the
|
|
1610
|
+
// next host Codex use, never silent HOST-credential corruption — so a teardown
|
|
1611
|
+
// fault never masks or fails the run result here.
|
|
1612
|
+
async function syncBackManagedHome(prepared) {
|
|
1613
|
+
if (prepared.remoteManagedHomeTeardown) {
|
|
1614
|
+
await prepared.remoteManagedHomeTeardown().catch(() => { });
|
|
1615
|
+
}
|
|
1616
|
+
// The per-session staging lease does NOT release here. The settlement releases
|
|
1617
|
+
// it last, in its own `finally`, so a same-session second run cannot re-stage
|
|
1618
|
+
// until this run fully settles and the caller observes the result.
|
|
1650
1619
|
}
|
|
1651
1620
|
function renderPaperclipEnvNote(env) {
|
|
1652
1621
|
const paperclipKeys = Object.keys(env)
|
|
@@ -1755,12 +1724,32 @@ async function buildPrompt(ctx, resumedSession, env) {
|
|
|
1755
1724
|
async function emitAcpxLog(ctx, payload) {
|
|
1756
1725
|
await ctx.onLog("stdout", `${JSON.stringify(payload)}\n`);
|
|
1757
1726
|
}
|
|
1727
|
+
/**
|
|
1728
|
+
* Build the short run summary that Paperclip may auto-post as an issue comment
|
|
1729
|
+
* when the agent leaves no comment of its own.
|
|
1730
|
+
*
|
|
1731
|
+
* Prefer the last non-empty *output* segment after a tool call. Intermediate
|
|
1732
|
+
* "let me check…" narration between tools must not become a 50k-char dump.
|
|
1733
|
+
* Thought-stream text is never included (callers must not push it into segments).
|
|
1734
|
+
*/
|
|
1735
|
+
export function buildAcpxRunSummary(input) {
|
|
1736
|
+
for (let i = input.outputSegments.length - 1; i >= 0; i -= 1) {
|
|
1737
|
+
const text = (input.outputSegments[i] ?? "").trim();
|
|
1738
|
+
if (text)
|
|
1739
|
+
return text;
|
|
1740
|
+
}
|
|
1741
|
+
const fallback = (input.fallback ?? "").trim();
|
|
1742
|
+
return fallback;
|
|
1743
|
+
}
|
|
1758
1744
|
// acpx substitutes a literal "tool call" title when an ACP tool_call_update
|
|
1759
1745
|
// omits one, which would persist a generic name over the real one ("Terminal",
|
|
1760
1746
|
// "Read", …) in the stored run log. Remember each call's real title so update
|
|
1761
|
-
// lines keep the name durably.
|
|
1747
|
+
// lines keep the name durably. Some ACP backends also stream partial tool
|
|
1748
|
+
// arguments as one in-progress update per token under this placeholder;
|
|
1749
|
+
// adapters that opt into coalescePlaceholderToolUpdates in their acpx config
|
|
1750
|
+
// have those updates coalesced until a real title is available.
|
|
1762
1751
|
const GENERIC_ACP_TOOL_TITLE = "tool call";
|
|
1763
|
-
async function emitRuntimeEvent(ctx, event, toolTitles) {
|
|
1752
|
+
async function emitRuntimeEvent(ctx, event, toolTitles, coalescePlaceholderToolUpdates) {
|
|
1764
1753
|
if (event.type === "text_delta") {
|
|
1765
1754
|
await emitAcpxLog(ctx, {
|
|
1766
1755
|
type: "acpx.text_delta",
|
|
@@ -1771,6 +1760,20 @@ async function emitRuntimeEvent(ctx, event, toolTitles) {
|
|
|
1771
1760
|
return;
|
|
1772
1761
|
}
|
|
1773
1762
|
if (event.type === "tool_call") {
|
|
1763
|
+
// Coalesce token-by-token argument streaming for adapters that opt in via
|
|
1764
|
+
// their acpx config: skip in-progress updates that still carry only the
|
|
1765
|
+
// unresolved placeholder title. Backends that stream tool arguments
|
|
1766
|
+
// otherwise emit tens of thousands of these per run, flooding the
|
|
1767
|
+
// transcript and pinning the live activity indicator to a generic
|
|
1768
|
+
// "tool call" instead of the real tool. The initial pending event, the
|
|
1769
|
+
// resolved-title in-progress update, and the terminal
|
|
1770
|
+
// completed/failed/cancelled update all still flow through. Adapters that
|
|
1771
|
+
// do not opt in never have an event dropped.
|
|
1772
|
+
if (coalescePlaceholderToolUpdates &&
|
|
1773
|
+
event.status === "in_progress" &&
|
|
1774
|
+
(event.title ?? "").trim() === GENERIC_ACP_TOOL_TITLE) {
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1774
1777
|
const eventRecord = event;
|
|
1775
1778
|
const toolInput = eventRecord.input;
|
|
1776
1779
|
let name = event.title ?? "acp_tool";
|
|
@@ -2039,27 +2042,10 @@ function isResumeFailure(err) {
|
|
|
2039
2042
|
const message = err instanceof Error ? err.message : String(err);
|
|
2040
2043
|
return /resume|load|not found|no session|unknown session|conversation/i.test(message);
|
|
2041
2044
|
}
|
|
2042
|
-
async function cleanupIdleHandles(input) {
|
|
2043
|
-
if (input.idleMs <= 0)
|
|
2044
|
-
return;
|
|
2045
|
-
const stale = [];
|
|
2046
|
-
for (const entry of input.handles.entries()) {
|
|
2047
|
-
if (input.now - entry[1].lastUsedAt >= input.idleMs)
|
|
2048
|
-
stale.push(entry);
|
|
2049
|
-
}
|
|
2050
|
-
for (const [key, entry] of stale) {
|
|
2051
|
-
await closeWarmHandle({
|
|
2052
|
-
handles: input.handles,
|
|
2053
|
-
key,
|
|
2054
|
-
entry,
|
|
2055
|
-
reason: "paperclip idle cleanup",
|
|
2056
|
-
});
|
|
2057
|
-
}
|
|
2058
|
-
}
|
|
2059
2045
|
// Drop staged-runtime entries the session has not touched within the warm-idle
|
|
2060
2046
|
// window, so the cache does not accumulate abandoned sessions (e.g. every time
|
|
2061
2047
|
// a config change shifts the fingerprint to a new key). The per-run copy-back
|
|
2062
|
-
// already ran on the entry's last run's `
|
|
2048
|
+
// already ran on the entry's last run's settlement `syncBack`; eviction fires
|
|
2063
2049
|
// the entry's one-time `dispose` (host staged-temp cleanup) — the only place
|
|
2064
2050
|
// the staged temp is removed now that it no longer rides the per-run teardown.
|
|
2065
2051
|
// A later run of the same session simply re-stages fresh (re-shipping into the
|
|
@@ -2193,31 +2179,6 @@ async function closeWarmHandle(input) {
|
|
|
2193
2179
|
}).catch(() => { });
|
|
2194
2180
|
flushChildStderr(input.entry.childStderrState);
|
|
2195
2181
|
}
|
|
2196
|
-
function scheduleIdleHandleCleanup(input) {
|
|
2197
|
-
clearWarmHandleTimer(input.entry);
|
|
2198
|
-
if (input.idleMs <= 0)
|
|
2199
|
-
return;
|
|
2200
|
-
const delayMs = Math.max(1, input.entry.lastUsedAt + input.idleMs - input.now());
|
|
2201
|
-
input.entry.cleanupTimer = setTimeout(() => {
|
|
2202
|
-
void (async () => {
|
|
2203
|
-
const current = input.handles.get(input.key);
|
|
2204
|
-
if (current !== input.entry)
|
|
2205
|
-
return;
|
|
2206
|
-
const idleForMs = input.now() - input.entry.lastUsedAt;
|
|
2207
|
-
if (idleForMs < input.idleMs) {
|
|
2208
|
-
scheduleIdleHandleCleanup(input);
|
|
2209
|
-
return;
|
|
2210
|
-
}
|
|
2211
|
-
await closeWarmHandle({
|
|
2212
|
-
handles: input.handles,
|
|
2213
|
-
key: input.key,
|
|
2214
|
-
entry: input.entry,
|
|
2215
|
-
reason: "paperclip idle cleanup",
|
|
2216
|
-
});
|
|
2217
|
-
})();
|
|
2218
|
-
}, delayMs);
|
|
2219
|
-
input.entry.cleanupTimer.unref?.();
|
|
2220
|
-
}
|
|
2221
2182
|
function warmHandleMatches(entry, runtime, handle) {
|
|
2222
2183
|
return entry !== undefined && entry.runtime === runtime && entry.handle === handle;
|
|
2223
2184
|
}
|
|
@@ -2438,6 +2399,24 @@ export function createAcpxEngineExecutor(deps = {}) {
|
|
|
2438
2399
|
billingType: billingIdentity?.billingType ?? "unknown",
|
|
2439
2400
|
};
|
|
2440
2401
|
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS);
|
|
2402
|
+
// The host run site owns the warm-handle store on this run. It operates over
|
|
2403
|
+
// the engine's persistent `warmHandles` map, so a warm runtime stays live for
|
|
2404
|
+
// the next compatible resume. The store arms the per-entry idle timer on a
|
|
2405
|
+
// warm save, sweeps idle entries at run start, and closes a released entry
|
|
2406
|
+
// through `closeWarmEntry`. The run reads `warmHandleIdleMs` per run, so the
|
|
2407
|
+
// site takes this run's bound.
|
|
2408
|
+
const hostSite = createHostRunSite({
|
|
2409
|
+
warmHandles,
|
|
2410
|
+
now,
|
|
2411
|
+
idleMs: warmIdleMs,
|
|
2412
|
+
closeWarmEntry: async (entry) => {
|
|
2413
|
+
await entry.runtime
|
|
2414
|
+
.close({ handle: entry.handle, reason: "paperclip idle cleanup", discardPersistentState: false })
|
|
2415
|
+
.catch(() => { });
|
|
2416
|
+
flushChildStderr(entry.childStderrState);
|
|
2417
|
+
},
|
|
2418
|
+
});
|
|
2419
|
+
const hostStore = hostSite.reuse();
|
|
2441
2420
|
// The `task.run` and `sandbox.startup` spans must not cover a local or SSH
|
|
2442
2421
|
// run: those runs have no sandbox, so they stay out of sandbox telemetry.
|
|
2443
2422
|
// Open a real root span only when the target is a remote sandbox and the
|
|
@@ -2485,6 +2464,12 @@ export function createAcpxEngineExecutor(deps = {}) {
|
|
|
2485
2464
|
// until the run reaches a clean completed turn, so every failure and every
|
|
2486
2465
|
// early exit closes the span with error status.
|
|
2487
2466
|
let runFailed = true;
|
|
2467
|
+
// The run's per-session staging lease release. `buildRuntime` returns it on
|
|
2468
|
+
// `prepared` after it acquires the lease; the startup step captures it here so
|
|
2469
|
+
// the run root `finally` can release it as the final settlement act. It stays
|
|
2470
|
+
// null on the host lane (no staging) and on a build failure (where
|
|
2471
|
+
// `buildRuntime` already released its own partial lease).
|
|
2472
|
+
let releaseStagingLease = null;
|
|
2488
2473
|
try {
|
|
2489
2474
|
// Evict idle staged runtimes BEFORE building the runtime, since buildRuntime
|
|
2490
2475
|
// consults the staged cache to decide whether a compatible resume may reuse
|
|
@@ -2545,247 +2530,328 @@ export function createAcpxEngineExecutor(deps = {}) {
|
|
|
2545
2530
|
let resumedSession = false;
|
|
2546
2531
|
let clearSession = false;
|
|
2547
2532
|
let referencedProjectStagingFailuresField = {};
|
|
2548
|
-
// One teardown policy for the run. `settleRunResources` stops the bridges,
|
|
2549
|
-
// runs the managed-home copy-back, releases the staging lease (in a finally
|
|
2550
|
-
// inside cleanupRemoteBridges), and flushes the child stderr. It runs at most
|
|
2551
|
-
// once, records each step error, and never throws, so a result-emission
|
|
2552
|
-
// failure or an earlier teardown fault never skips a later step.
|
|
2553
|
-
let runResourcesSettled = false;
|
|
2554
|
-
// `turnTeardownDone` marks that the turn path already closed the runtime and
|
|
2555
|
-
// settled the staged runtime, so a result-mapping throw after the close does
|
|
2556
|
-
// not run the turn teardown a second time.
|
|
2557
|
-
let turnTeardownDone = false;
|
|
2558
2533
|
const recordTeardownError = async (step, teardownErr) => {
|
|
2559
2534
|
const reason = teardownErr instanceof Error ? teardownErr.message : String(teardownErr);
|
|
2560
2535
|
await ctx
|
|
2561
2536
|
.onLog("stderr", `[paperclip] ACPX teardown step "${step}" failed: ${reason}\n`)
|
|
2562
2537
|
.catch(() => { });
|
|
2563
2538
|
};
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2539
|
+
// Emit one per-phase timing run-log event. It is not an OpenTelemetry
|
|
2540
|
+
// export and it is not a Telemetry event: it carries the phase name
|
|
2541
|
+
// (from the closed allowlist), the wall time, and the outcome, and
|
|
2542
|
+
// never a command, a path, an environment value, or an identifier. A
|
|
2543
|
+
// failure to emit this event never fails the run.
|
|
2544
|
+
const emitPhase = (phase, startMs, outcome) => emitRunPhaseTiming(ctx, phase, now() - startMs, outcome);
|
|
2545
|
+
// Time a settlement step and emit its phase timing on every path. A step
|
|
2546
|
+
// error still emits `failed` before it re-throws to the Phase 3 error policy.
|
|
2547
|
+
const timedPhase = async (phase, run) => {
|
|
2548
|
+
const start = now();
|
|
2568
2549
|
try {
|
|
2569
|
-
await
|
|
2550
|
+
await run();
|
|
2570
2551
|
}
|
|
2571
|
-
catch (
|
|
2572
|
-
await
|
|
2573
|
-
|
|
2574
|
-
try {
|
|
2575
|
-
if (childStderrState)
|
|
2576
|
-
flushChildStderr(childStderrState);
|
|
2577
|
-
}
|
|
2578
|
-
catch (teardownErr) {
|
|
2579
|
-
await recordTeardownError("flush-child-stderr", teardownErr);
|
|
2552
|
+
catch (error) {
|
|
2553
|
+
await emitPhase(phase, start, "failed");
|
|
2554
|
+
throw error;
|
|
2580
2555
|
}
|
|
2556
|
+
await emitPhase(phase, start, "ok");
|
|
2581
2557
|
};
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
spawnCwd: prepared.hostSpawnCwd,
|
|
2630
|
-
sessionStore: createRuntimeStore({ stateDir: prepared.stateDir }),
|
|
2631
|
-
agentRegistry: prepared.agentRegistry,
|
|
2632
|
-
permissionMode: prepared.permissionMode,
|
|
2633
|
-
nonInteractivePermissions: prepared.nonInteractivePermissions,
|
|
2634
|
-
mcpServers: prepared.mcpServers,
|
|
2635
|
-
timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined,
|
|
2636
|
-
// Scope ACPX runtime verbose logs to the claude agent only. Codex
|
|
2637
|
-
// and custom agents already emit their own per-tool output and don't
|
|
2638
|
-
// benefit from doubling the log volume.
|
|
2639
|
-
verbose: prepared.acpxAgent === "claude",
|
|
2640
|
-
onAgentStderr: prepared.childStderrLogPath
|
|
2641
|
-
? (chunk) => routeChildStderr(childStderrState, chunk)
|
|
2642
|
-
: undefined,
|
|
2643
|
-
onAgentSpawn: async (meta) => {
|
|
2644
|
-
processIdentitySink.latest = meta;
|
|
2645
|
-
await processIdentitySink.current?.({
|
|
2646
|
-
pid: meta.pid,
|
|
2647
|
-
processGroupId: null,
|
|
2648
|
-
startedAt: meta.startedAt,
|
|
2649
|
-
});
|
|
2650
|
-
},
|
|
2651
|
-
getRuntimeParentContext,
|
|
2652
|
-
};
|
|
2653
|
-
// Open Q2: split the ~7s `acp.handshake` into the two in-repo-observable
|
|
2654
|
-
// sub-phases — the ACP runtime construction (`createRuntime`) vs the session
|
|
2655
|
-
// establishment envelope (`ensureSession`). The patched spawn lifecycle
|
|
2656
|
-
// hook records process identity, but the finer spawn/`initialize`/
|
|
2657
|
-
// `session/new` timing split still lives inside external `acpx`.
|
|
2658
|
-
// `createRuntime` runs once and only on a cold start; a warm-handle hit
|
|
2659
|
-
// reuses `cached.runtime`, so `createRuntimeMs` stays undefined and the
|
|
2660
|
-
// split reports nothing for it.
|
|
2661
|
-
let createRuntimeMs;
|
|
2662
|
-
// A warm handle reuses the running ACP runtime; a miss constructs one. The
|
|
2663
|
-
// root span records this as `cold_start`.
|
|
2664
|
-
coldStart = !cached?.runtime;
|
|
2665
|
-
if (cached?.runtime) {
|
|
2666
|
-
runtime = cached.runtime;
|
|
2667
|
-
}
|
|
2668
|
-
else {
|
|
2669
|
-
const createRuntimeStart = now();
|
|
2670
|
-
runtime = createRuntime(runtimeOptions);
|
|
2671
|
-
createRuntimeMs = now() - createRuntimeStart;
|
|
2672
|
-
}
|
|
2673
|
-
if (cached)
|
|
2674
|
-
clearWarmHandleTimer(cached);
|
|
2675
|
-
if (!canResume && asString(previousParams.runtimeSessionName, "")) {
|
|
2676
|
-
await ctx.onLog("stdout", `[paperclip] ACPX session "${asString(previousParams.runtimeSessionName, "")}" does not match the current agent/cwd/mode/runtime identity; starting fresh in "${prepared.cwd}".\n`);
|
|
2677
|
-
}
|
|
2678
|
-
let handle = cached?.handle ?? null;
|
|
2679
|
-
resumedSession = Boolean(handle ?? resumeSessionId);
|
|
2558
|
+
// The turn the run started. It is hoisted to the run scope so the settlement
|
|
2559
|
+
// `endSession` step can cancel a running turn before it closes the runtime
|
|
2560
|
+
// (the cancel-before-close order). The turn wrapper assigns it in `turnStart`.
|
|
2561
|
+
let activeTurn = null;
|
|
2562
|
+
// How the settlement `endSession` step must release the runtime for the path
|
|
2563
|
+
// this run took. Each exit path that acquired the runtime records it before it
|
|
2564
|
+
// returns; a build or create-runtime failure never registers the runtime, so
|
|
2565
|
+
// the step no-ops on the empty slot and this stays null.
|
|
2566
|
+
let runtimeSettlement = null;
|
|
2567
|
+
// A synthetic close handle from the prepared identity, for a close that has no
|
|
2568
|
+
// established session handle (a cold `ensureSession` throw or a missing
|
|
2569
|
+
// handle). `runtime.close` reads the session key and the runtime session name.
|
|
2570
|
+
const syntheticCloseHandle = () => ({
|
|
2571
|
+
sessionKey: prepared.sessionKey,
|
|
2572
|
+
backend: prepared.acpxAgent,
|
|
2573
|
+
runtimeSessionName: prepared.sessionKey,
|
|
2574
|
+
});
|
|
2575
|
+
// The run coordinator owns the one ledger for the attempt. The engine
|
|
2576
|
+
// creates it here, passes it into `buildRuntime` (the sandbox site registers
|
|
2577
|
+
// what it acquires into it), and hands it to `runAttempt` as the plan
|
|
2578
|
+
// ledger. The turn and settlement code still runs inline in the step
|
|
2579
|
+
// wrappers below; Phases 21-22 move it into its own modules.
|
|
2580
|
+
const runResourceLedger = createRunResourceLedger();
|
|
2581
|
+
// The step wrappers build the external result inline (today's behavior) and
|
|
2582
|
+
// record it here; the coordinator reproduces it after settlement. An empty
|
|
2583
|
+
// consumed set satisfies the `settle` result shape without claiming the run
|
|
2584
|
+
// ledger, because the inline teardown reads `prepared.*`, not the ledger, in
|
|
2585
|
+
// this phase.
|
|
2586
|
+
let capturedResult = null;
|
|
2587
|
+
const emptyConsumed = createRunResourceLedger().takeForSettlement();
|
|
2588
|
+
// Build the `settle` startup result for a pre-turn failure. The step wrapper
|
|
2589
|
+
// already recorded the external result and ran the inline teardown; this
|
|
2590
|
+
// carries only the routing kind and the cause for the coordinator.
|
|
2591
|
+
const settleFor = (phase, error) => ({
|
|
2592
|
+
kind: "settle",
|
|
2593
|
+
cause: {
|
|
2594
|
+
kind: "pre_turn_failed",
|
|
2595
|
+
phase,
|
|
2596
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
2597
|
+
},
|
|
2598
|
+
resources: emptyConsumed,
|
|
2599
|
+
});
|
|
2600
|
+
// The startup step: bring up the runtime, establish the session, and apply
|
|
2601
|
+
// the session config. It returns `ready` on success, `settle` on a pre-turn
|
|
2602
|
+
// failure after the ledger acquired resources, and throws on a build failure
|
|
2603
|
+
// or a partial-bridge failure (the coordinator owns that rollback).
|
|
2604
|
+
const startup = async () => {
|
|
2680
2605
|
try {
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2606
|
+
// Publish the `sandbox.startup` context to the runtime-parent store for
|
|
2607
|
+
// the whole bring-up. A startup-body exec that runs outside a measured
|
|
2608
|
+
// step reads this token and parents its span to `sandbox.startup`, not to
|
|
2609
|
+
// a detached root. A measured step nests its own `activeStepContextStorage`
|
|
2610
|
+
// run inside this wrap and overrides the store, so an in-step exec still
|
|
2611
|
+
// parents to its step span. On a local or SSH target
|
|
2612
|
+
// `spanParent.parentContext` is a no-op token, so the wrap is inert.
|
|
2613
|
+
prepared = await runWithRuntimeParent(spanParent.parentContext, () => buildRuntime({
|
|
2614
|
+
ctx,
|
|
2615
|
+
engine,
|
|
2616
|
+
deps,
|
|
2617
|
+
ledger: runResourceLedger,
|
|
2618
|
+
stagedIdleMs: warmIdleMs,
|
|
2619
|
+
spanParent,
|
|
2620
|
+
getRuntimeParentContext,
|
|
2621
|
+
runtimeSpan: runRuntimeSpan,
|
|
2622
|
+
stageRuntimeSpan: runStageSpan,
|
|
2623
|
+
}));
|
|
2624
|
+
buildRuntimeSettled = true;
|
|
2625
|
+
// Capture the run's staging lease release now that the runtime built. The
|
|
2626
|
+
// run root `finally` releases it as the final settlement act.
|
|
2627
|
+
releaseStagingLease = prepared.sessionStagingLeaseRelease;
|
|
2628
|
+
// Per-project staging outcomes for the referenced (mentioned) projects, surfaced back to the
|
|
2629
|
+
// server on the run result. A referenced project that failed to stage into the sandbox is a
|
|
2630
|
+
// first-class, counted failure in the requested-vs-synced observability, not only a warning. The
|
|
2631
|
+
// list is empty on a local target, on a transport that does not stage referenced projects, or
|
|
2632
|
+
// when every staged referenced project succeeded, so the spread adds the field only when there
|
|
2633
|
+
// is a failure to report.
|
|
2634
|
+
const referencedProjectStagingFailures = (prepared.stagedRuntime?.additionalSourceFailures ?? []).map((failure) => ({ projectId: failure.projectId, error: failure.error }));
|
|
2635
|
+
referencedProjectStagingFailuresField =
|
|
2636
|
+
referencedProjectStagingFailures.length > 0 ? { referencedProjectStagingFailures } : {};
|
|
2637
|
+
// Write one run-log line per failure, so a reader of the run log alone sees
|
|
2638
|
+
// each dropped referenced project and its reason. The run continues without
|
|
2639
|
+
// the failed project (per-project failure isolation). Goes to stderr: the
|
|
2640
|
+
// acpx stdout log stream carries machine-parseable JSON event payloads.
|
|
2641
|
+
for (const failure of referencedProjectStagingFailures) {
|
|
2642
|
+
await ctx.onLog("stderr", `[paperclip] Referenced project ${failure.projectId} failed to stage; the run continues without it: ${failure.error}\n`);
|
|
2643
|
+
}
|
|
2644
|
+
// State the effective wall-clock timeout and its source up front so a
|
|
2645
|
+
// later timeout is diagnosable from the run log alone. Goes to stderr:
|
|
2646
|
+
// the acpx stdout log stream carries JSON acpx.* event payloads and must
|
|
2647
|
+
// stay machine-parseable line by line.
|
|
2648
|
+
await ctx.onLog("stderr", `[paperclip] ${formatAdapterExecutionTimeoutStartLogLine(prepared.timeoutResolution)}\n`);
|
|
2649
|
+
await hostStore.evictIdle(now());
|
|
2650
|
+
const previousParams = parseObject(ctx.runtime.sessionParams);
|
|
2651
|
+
const canResume = isCompatibleSession(previousParams, prepared);
|
|
2652
|
+
const resumeSessionId = canResume ? asString(previousParams.acpSessionId, "") || undefined : undefined;
|
|
2653
|
+
// Borrow the warm entry without removing it, so an overlapping run of the
|
|
2654
|
+
// same session still sees it. The borrow clears the entry's idle timer, so
|
|
2655
|
+
// the reused runtime cannot expire under its own timer while this run uses
|
|
2656
|
+
// it (today's `clearWarmHandleTimer(cached)` after the reuse decision).
|
|
2657
|
+
const cached = canResume ? hostStore.borrow(prepared.sessionKey) : undefined;
|
|
2658
|
+
childStderrState = cached?.childStderrState ?? { logPath: null, pendingLiveLine: "" };
|
|
2659
|
+
processIdentitySink = cached?.processIdentitySink ?? {
|
|
2660
|
+
current: ctx.onSpawn,
|
|
2661
|
+
latest: null,
|
|
2662
|
+
};
|
|
2663
|
+
// ACPX runtimes can stay warm across heartbeat runs. Keep the callback
|
|
2664
|
+
// target mutable so a later agent respawn records identity on the current
|
|
2665
|
+
// heartbeat instead of the run that originally created the runtime.
|
|
2666
|
+
processIdentitySink.current = ctx.onSpawn;
|
|
2667
|
+
flushChildStderr(childStderrState);
|
|
2668
|
+
childStderrState.logPath = prepared.childStderrLogPath;
|
|
2669
|
+
const runtimeOptions = {
|
|
2670
|
+
cwd: prepared.cwd,
|
|
2671
|
+
// Host-only spawn cwd for the relay proxy on the remote process-session
|
|
2672
|
+
// lane; `undefined` elsewhere so acpx falls back to `cwd` (byte-identical).
|
|
2673
|
+
// The advertised `session/new` cwd (`prepared.cwd` = `remoteCwd`) and the
|
|
2674
|
+
// fingerprint / compat key are unaffected — this redirects ONLY the host
|
|
2675
|
+
// `spawn()` `chdir`, not the in-sandbox data path.
|
|
2676
|
+
spawnCwd: prepared.hostSpawnCwd,
|
|
2677
|
+
sessionStore: createRuntimeStore({ stateDir: prepared.stateDir }),
|
|
2678
|
+
agentRegistry: prepared.agentRegistry,
|
|
2679
|
+
permissionMode: prepared.permissionMode,
|
|
2680
|
+
nonInteractivePermissions: prepared.nonInteractivePermissions,
|
|
2681
|
+
mcpServers: prepared.mcpServers,
|
|
2682
|
+
timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined,
|
|
2683
|
+
// Scope ACPX runtime verbose logs to the claude agent only. Codex
|
|
2684
|
+
// and custom agents already emit their own per-tool output and don't
|
|
2685
|
+
// benefit from doubling the log volume.
|
|
2686
|
+
verbose: prepared.acpxAgent === "claude",
|
|
2687
|
+
onAgentStderr: prepared.childStderrLogPath
|
|
2688
|
+
? (chunk) => routeChildStderr(childStderrState, chunk)
|
|
2689
|
+
: undefined,
|
|
2690
|
+
onAgentSpawn: async (meta) => {
|
|
2691
|
+
processIdentitySink.latest = meta;
|
|
2692
|
+
await processIdentitySink.current?.({
|
|
2693
|
+
pid: meta.pid,
|
|
2694
|
+
processGroupId: null,
|
|
2695
|
+
startedAt: meta.startedAt,
|
|
2696
|
+
});
|
|
2697
|
+
},
|
|
2698
|
+
getRuntimeParentContext,
|
|
2699
|
+
};
|
|
2700
|
+
// Open Q2: split the ~7s `acp.handshake` into the two in-repo-observable
|
|
2701
|
+
// sub-phases — the ACP runtime construction (`createRuntime`) vs the session
|
|
2702
|
+
// establishment envelope (`ensureSession`). The patched spawn lifecycle
|
|
2703
|
+
// hook records process identity, but the finer spawn/`initialize`/
|
|
2704
|
+
// `session/new` timing split still lives inside external `acpx`.
|
|
2705
|
+
// `createRuntime` runs once and only on a cold start; a warm-handle hit
|
|
2706
|
+
// reuses `cached.runtime`, so `createRuntimeMs` stays undefined and the
|
|
2707
|
+
// split reports nothing for it.
|
|
2708
|
+
let createRuntimeMs;
|
|
2709
|
+
// A warm handle reuses the running ACP runtime; a miss constructs one. The
|
|
2710
|
+
// root span records this as `cold_start`.
|
|
2711
|
+
coldStart = !cached?.runtime;
|
|
2712
|
+
if (cached?.runtime) {
|
|
2713
|
+
runtime = cached.runtime;
|
|
2714
|
+
}
|
|
2715
|
+
else {
|
|
2716
|
+
const createRuntimeStart = now();
|
|
2717
|
+
runtime = createRuntime(runtimeOptions);
|
|
2718
|
+
createRuntimeMs = now() - createRuntimeStart;
|
|
2719
|
+
// The create_runtime phase runs only on a cold start.
|
|
2720
|
+
await emitRunPhaseTiming(ctx, "create_runtime", createRuntimeMs, "ok");
|
|
2721
|
+
}
|
|
2722
|
+
// Register the runtime composite in the ledger now that it exists. The
|
|
2723
|
+
// settlement `endSession` step closes it on every path the reuse decision
|
|
2724
|
+
// does not transfer. Registration here (before `ensureSession`) closes the
|
|
2725
|
+
// cold-handshake leak: a cold `ensureSession` throw before a handle exists
|
|
2726
|
+
// still leaves the runtime in the ledger for `endSession` to close. A
|
|
2727
|
+
// create-runtime failure throws before this line, so the runtime never
|
|
2728
|
+
// enters the ledger and the settlement no-ops on the empty slot. The
|
|
2729
|
+
// session handle fills in below; the payload carries the best-known handle
|
|
2730
|
+
// for the composite (`runtime.close` is the one release boundary).
|
|
2731
|
+
runResourceLedger.register({
|
|
2732
|
+
id: "acp_runtime",
|
|
2733
|
+
payload: {
|
|
2734
|
+
runtime,
|
|
2735
|
+
sessionHandle: cached?.handle ?? syntheticCloseHandle(),
|
|
2736
|
+
childProcessPid: processIdentitySink.latest?.pid ?? null,
|
|
2737
|
+
},
|
|
2738
|
+
scope: "per_run",
|
|
2739
|
+
});
|
|
2740
|
+
if (!canResume && asString(previousParams.runtimeSessionName, "")) {
|
|
2741
|
+
await ctx.onLog("stdout", `[paperclip] ACPX session "${asString(previousParams.runtimeSessionName, "")}" does not match the current agent/cwd/mode/runtime identity; starting fresh in "${prepared.cwd}".\n`);
|
|
2742
|
+
}
|
|
2743
|
+
let handle = cached?.handle ?? null;
|
|
2744
|
+
// The ensure_session phase covers the handshake (session establishment or
|
|
2745
|
+
// resume) up to the established handle.
|
|
2746
|
+
const ensureSessionPhaseStart = now();
|
|
2747
|
+
resumedSession = Boolean(handle ?? resumeSessionId);
|
|
2748
|
+
try {
|
|
2749
|
+
if (!handle) {
|
|
2750
|
+
try {
|
|
2751
|
+
// Step 7 — acp.handshake: ACP session establishment (session/new or
|
|
2752
|
+
// resume). A throwing handshake still reports its duration before the
|
|
2753
|
+
// resume-retry path below runs. The createRuntime/ensureSession
|
|
2754
|
+
// sub-split rides the step span as fixed, closed keys (Open Q2).
|
|
2755
|
+
let ensureSessionMs;
|
|
2756
|
+
handle = await measureStartupStep(ctx, now, "acp.handshake", async () => {
|
|
2757
|
+
const ensureSessionStart = now();
|
|
2758
|
+
const established = await runtime.ensureSession({
|
|
2759
|
+
sessionKey: prepared.sessionKey,
|
|
2760
|
+
agent: prepared.acpxAgent,
|
|
2761
|
+
mode: prepared.mode,
|
|
2762
|
+
cwd: prepared.cwd,
|
|
2763
|
+
resumeSessionId,
|
|
2764
|
+
sessionOptions: { env: prepared.env },
|
|
2765
|
+
});
|
|
2766
|
+
ensureSessionMs = now() - ensureSessionStart;
|
|
2767
|
+
return established;
|
|
2768
|
+
}, {
|
|
2769
|
+
...prepared.stepMetrics,
|
|
2770
|
+
// The two sub-times ride the span as fixed, closed keys.
|
|
2771
|
+
spanWallTimes: () => ({
|
|
2772
|
+
createRuntime: createRuntimeMs,
|
|
2773
|
+
ensureSession: ensureSessionMs,
|
|
2774
|
+
}),
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
catch (err) {
|
|
2778
|
+
if (!resumeSessionId || !isResumeFailure(err))
|
|
2779
|
+
throw err;
|
|
2780
|
+
clearSession = true;
|
|
2781
|
+
resumedSession = false;
|
|
2782
|
+
await ctx.onLog("stdout", `[paperclip] ACPX resume session "${resumeSessionId}" is unavailable; retrying with a fresh session.\n`);
|
|
2783
|
+
// Fresh-session retry: the runtime was already constructed on the
|
|
2784
|
+
// first attempt (never re-created), so this event reports only its
|
|
2785
|
+
// own `ensureSessionMs` — no `createRuntimeMs`.
|
|
2786
|
+
let retryEnsureSessionMs;
|
|
2787
|
+
handle = await measureStartupStep(ctx, now, "acp.handshake", async () => {
|
|
2788
|
+
const ensureSessionStart = now();
|
|
2789
|
+
const established = await runtime.ensureSession({
|
|
2790
|
+
sessionKey: prepared.sessionKey,
|
|
2791
|
+
agent: prepared.acpxAgent,
|
|
2792
|
+
mode: prepared.mode,
|
|
2793
|
+
cwd: prepared.cwd,
|
|
2794
|
+
sessionOptions: { env: prepared.env },
|
|
2795
|
+
});
|
|
2796
|
+
retryEnsureSessionMs = now() - ensureSessionStart;
|
|
2797
|
+
return established;
|
|
2798
|
+
}, {
|
|
2799
|
+
...prepared.stepMetrics,
|
|
2800
|
+
// The retry reuses the runtime from the first attempt, so it reports
|
|
2801
|
+
// only its own ensure-session sub-time on the span.
|
|
2802
|
+
spanWallTimes: () => ({ ensureSession: retryEnsureSessionMs }),
|
|
2697
2803
|
});
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
else {
|
|
2807
|
+
// Warm-handle hit: a compatible cached handle reuses the running ACP
|
|
2808
|
+
// agent, so the `acp.handshake` step does no work. Emit a step span and
|
|
2809
|
+
// event with `outcome = skipped` and a zero wall time, so the trace and
|
|
2810
|
+
// the run log show the skip as a distinct outcome, never a misleading
|
|
2811
|
+
// zero-work `ok` step.
|
|
2812
|
+
await emitSkippedStartupStep(ctx, "acp.handshake", {
|
|
2813
|
+
tracer: prepared.stepMetrics.tracer,
|
|
2814
|
+
parentContext: prepared.stepMetrics.parentContext,
|
|
2707
2815
|
});
|
|
2708
2816
|
}
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
// own `ensureSessionMs` — no `createRuntimeMs`.
|
|
2718
|
-
let retryEnsureSessionMs;
|
|
2719
|
-
handle = await measureStartupStep(ctx, now, "acp.handshake", async () => {
|
|
2720
|
-
const ensureSessionStart = now();
|
|
2721
|
-
const established = await runtime.ensureSession({
|
|
2722
|
-
sessionKey: prepared.sessionKey,
|
|
2723
|
-
agent: prepared.acpxAgent,
|
|
2724
|
-
mode: prepared.mode,
|
|
2725
|
-
cwd: prepared.cwd,
|
|
2726
|
-
sessionOptions: { env: prepared.env },
|
|
2727
|
-
});
|
|
2728
|
-
retryEnsureSessionMs = now() - ensureSessionStart;
|
|
2729
|
-
return established;
|
|
2730
|
-
}, {
|
|
2731
|
-
...prepared.stepMetrics,
|
|
2732
|
-
// The retry reuses the runtime from the first attempt, so it reports
|
|
2733
|
-
// only its own ensure-session sub-time on the span.
|
|
2734
|
-
spanWallTimes: () => ({ ensureSession: retryEnsureSessionMs }),
|
|
2817
|
+
// A compatible warm handle reuses the already-running ACP agent and does
|
|
2818
|
+
// not emit another spawn event. Persist its known identity on this run
|
|
2819
|
+
// before the next prompt starts so every running heartbeat is adoptable.
|
|
2820
|
+
if (handle && cached && processIdentitySink.latest && ctx.onSpawn) {
|
|
2821
|
+
await ctx.onSpawn({
|
|
2822
|
+
pid: processIdentitySink.latest.pid,
|
|
2823
|
+
processGroupId: null,
|
|
2824
|
+
startedAt: processIdentitySink.latest.startedAt,
|
|
2735
2825
|
});
|
|
2736
2826
|
}
|
|
2737
2827
|
}
|
|
2738
|
-
|
|
2739
|
-
//
|
|
2740
|
-
//
|
|
2741
|
-
//
|
|
2742
|
-
//
|
|
2743
|
-
//
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
// before the next prompt starts so every running heartbeat is adoptable.
|
|
2752
|
-
if (handle && cached && processIdentitySink.latest && ctx.onSpawn) {
|
|
2753
|
-
await ctx.onSpawn({
|
|
2754
|
-
pid: processIdentitySink.latest.pid,
|
|
2755
|
-
processGroupId: null,
|
|
2756
|
-
startedAt: processIdentitySink.latest.startedAt,
|
|
2757
|
-
});
|
|
2758
|
-
}
|
|
2759
|
-
}
|
|
2760
|
-
catch (err) {
|
|
2761
|
-
// Close the runtime on a pre-turn handshake failure and drop the matching
|
|
2762
|
-
// warm entry, the same as the configuration failure path. A warm-hit
|
|
2763
|
-
// reuse already cleared the idle timer before the handshake ran, so the
|
|
2764
|
-
// failure must close and remove the entry, never re-arm it. The close and
|
|
2765
|
-
// the staged-runtime drop run before the result emission, so a throwing
|
|
2766
|
-
// emission never skips them; `settleRunResources` in the finally stops the
|
|
2767
|
-
// bridges, releases the staging lease, and flushes the child stderr.
|
|
2768
|
-
if (handle) {
|
|
2769
|
-
await runtime.close({
|
|
2770
|
-
handle,
|
|
2828
|
+
catch (err) {
|
|
2829
|
+
// Record how the settlement closes the runtime on a pre-turn handshake
|
|
2830
|
+
// failure: a direct close that drops a matching warm entry, the same as
|
|
2831
|
+
// the configuration-failure path. A warm-hit reuse already cleared the
|
|
2832
|
+
// idle timer before the handshake ran, so the failure must close and
|
|
2833
|
+
// remove the entry, never re-arm it. A cold `ensureSession` throw has no
|
|
2834
|
+
// established handle, so the settlement closes the synthetic handle — the
|
|
2835
|
+
// ledger now holds the runtime, so `endSession` closes it and the former
|
|
2836
|
+
// cold-handshake leak is gone. The settlement stops the bridges, releases
|
|
2837
|
+
// the staging lease, and flushes the child stderr on every exit path.
|
|
2838
|
+
runtimeSettlement = {
|
|
2839
|
+
mode: "direct",
|
|
2840
|
+
handle: handle ?? syntheticCloseHandle(),
|
|
2771
2841
|
reason: "paperclip handshake cleanup",
|
|
2772
2842
|
discardPersistentState: false,
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
}
|
|
2779
|
-
}
|
|
2780
|
-
await discardStagedRuntime({ handles: stagedRuntimes, prepared });
|
|
2781
|
-
try {
|
|
2843
|
+
dropWarmEntry: true,
|
|
2844
|
+
recordCloseError: true,
|
|
2845
|
+
cancelTurnReason: null,
|
|
2846
|
+
};
|
|
2847
|
+
await emitPhase("ensure_session", ensureSessionPhaseStart, "failed");
|
|
2782
2848
|
const { classified, message } = await emitAcpxFailure({
|
|
2783
2849
|
ctx,
|
|
2784
2850
|
prepared,
|
|
2785
2851
|
err,
|
|
2786
2852
|
phase: "ensure_session",
|
|
2787
2853
|
});
|
|
2788
|
-
|
|
2854
|
+
capturedResult = {
|
|
2789
2855
|
exitCode: 1,
|
|
2790
2856
|
signal: null,
|
|
2791
2857
|
timedOut: false,
|
|
@@ -2798,28 +2864,23 @@ export function createAcpxEngineExecutor(deps = {}) {
|
|
|
2798
2864
|
resultJson: { phase: "ensure_session" },
|
|
2799
2865
|
summary: message,
|
|
2800
2866
|
};
|
|
2867
|
+
return settleFor("handshake", err);
|
|
2801
2868
|
}
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
reason: "paperclip missing-handle cleanup",
|
|
2818
|
-
discardPersistentState: false,
|
|
2819
|
-
}).catch((closeErr) => recordTeardownError("runtime-close", closeErr));
|
|
2820
|
-
await discardStagedRuntime({ handles: stagedRuntimes, prepared });
|
|
2821
|
-
try {
|
|
2822
|
-
return {
|
|
2869
|
+
if (!handle) {
|
|
2870
|
+
// ensureSession returned no session handle. The ledger holds the runtime
|
|
2871
|
+
// the run constructed, so record a direct close of the synthetic handle
|
|
2872
|
+
// for the settlement `endSession` step (the child process cannot leak).
|
|
2873
|
+
runtimeSettlement = {
|
|
2874
|
+
mode: "direct",
|
|
2875
|
+
handle: syntheticCloseHandle(),
|
|
2876
|
+
reason: "paperclip missing-handle cleanup",
|
|
2877
|
+
discardPersistentState: false,
|
|
2878
|
+
dropWarmEntry: false,
|
|
2879
|
+
recordCloseError: true,
|
|
2880
|
+
cancelTurnReason: null,
|
|
2881
|
+
};
|
|
2882
|
+
await emitPhase("ensure_session", ensureSessionPhaseStart, "failed");
|
|
2883
|
+
capturedResult = {
|
|
2823
2884
|
exitCode: 1,
|
|
2824
2885
|
signal: null,
|
|
2825
2886
|
timedOut: false,
|
|
@@ -2831,35 +2892,32 @@ export function createAcpxEngineExecutor(deps = {}) {
|
|
|
2831
2892
|
resultJson: { phase: "ensure_session" },
|
|
2832
2893
|
summary: "ACPX did not return a runtime session handle.",
|
|
2833
2894
|
};
|
|
2895
|
+
return settleFor("session_handle_missing", new Error("ACPX did not return a runtime session handle."));
|
|
2834
2896
|
}
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
}
|
|
2839
|
-
sessionHandle = handle;
|
|
2840
|
-
startupFailed = false;
|
|
2841
|
-
}
|
|
2842
|
-
catch (err) {
|
|
2843
|
-
if (!buildRuntimeSettled) {
|
|
2844
|
-
// buildRuntime failed before it staged or bridged anything, so there is
|
|
2845
|
-
// nothing to settle here. The finally below ends the sandbox.startup
|
|
2846
|
-
// span; let the failure propagate.
|
|
2847
|
-
throw err;
|
|
2897
|
+
sessionHandle = handle;
|
|
2898
|
+
startupFailed = false;
|
|
2899
|
+
await emitPhase("ensure_session", ensureSessionPhaseStart, "ok");
|
|
2848
2900
|
}
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2901
|
+
catch (err) {
|
|
2902
|
+
if (!buildRuntimeSettled) {
|
|
2903
|
+
// buildRuntime failed before it staged or bridged anything, so there is
|
|
2904
|
+
// nothing to settle here. The finally below ends the sandbox.startup
|
|
2905
|
+
// span; let the failure propagate.
|
|
2906
|
+
throw err;
|
|
2907
|
+
}
|
|
2908
|
+
// The post-build runtime-creation window failed after buildRuntime returned
|
|
2909
|
+
// live bridges and a held staging lease. `createRuntime` throws before the
|
|
2910
|
+
// ledger registers the runtime, so the settlement `endSession` step no-ops
|
|
2911
|
+
// on the empty slot (create_runtime never closes a runtime). The settlement
|
|
2912
|
+
// still stops the bridges, discards the staged runtime, releases the staging
|
|
2913
|
+
// lease, and flushes the child stderr.
|
|
2856
2914
|
const { classified, message } = await emitAcpxFailure({
|
|
2857
2915
|
ctx,
|
|
2858
2916
|
prepared,
|
|
2859
2917
|
err,
|
|
2860
2918
|
phase: "create_runtime",
|
|
2861
2919
|
});
|
|
2862
|
-
|
|
2920
|
+
capturedResult = {
|
|
2863
2921
|
exitCode: 1,
|
|
2864
2922
|
signal: null,
|
|
2865
2923
|
timedOut: false,
|
|
@@ -2872,49 +2930,46 @@ export function createAcpxEngineExecutor(deps = {}) {
|
|
|
2872
2930
|
resultJson: { phase: "create_runtime" },
|
|
2873
2931
|
summary: message,
|
|
2874
2932
|
};
|
|
2933
|
+
return settleFor("runtime_create", err);
|
|
2875
2934
|
}
|
|
2876
2935
|
finally {
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
// End the sandbox.startup span exactly once, on every return and on every
|
|
2882
|
-
// throw. It covers buildRuntime through acp.handshake and no further; the
|
|
2883
|
-
// agent turn runs after and is out of the span's scope.
|
|
2884
|
-
rootSpan.end(startupFailed);
|
|
2885
|
-
}
|
|
2886
|
-
try {
|
|
2887
|
-
await applySessionConfigOptions({
|
|
2888
|
-
runtime,
|
|
2889
|
-
handle: sessionHandle,
|
|
2890
|
-
prepared,
|
|
2891
|
-
onLog: ctx.onLog,
|
|
2892
|
-
});
|
|
2893
|
-
}
|
|
2894
|
-
catch (err) {
|
|
2895
|
-
// Close the runtime and drop the matching warm entry and the staged runtime
|
|
2896
|
-
// before the result emission, so a throwing emission never skips them.
|
|
2897
|
-
// `settleRunResources` in the finally stops the bridges, releases the
|
|
2898
|
-
// staging lease, and flushes the child stderr.
|
|
2899
|
-
await runtime.close({
|
|
2900
|
-
handle: sessionHandle,
|
|
2901
|
-
reason: "paperclip config cleanup",
|
|
2902
|
-
discardPersistentState: false,
|
|
2903
|
-
}).catch((closeErr) => recordTeardownError("runtime-close", closeErr));
|
|
2904
|
-
const existing = warmHandles.get(prepared.sessionKey);
|
|
2905
|
-
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
|
|
2906
|
-
clearWarmHandleTimer(existing);
|
|
2907
|
-
warmHandles.delete(prepared.sessionKey);
|
|
2936
|
+
// End the sandbox.startup span exactly once, on every return and on every
|
|
2937
|
+
// throw. It covers buildRuntime through acp.handshake and no further; the
|
|
2938
|
+
// agent turn runs after and is out of the span's scope.
|
|
2939
|
+
rootSpan.end(startupFailed);
|
|
2908
2940
|
}
|
|
2909
|
-
|
|
2941
|
+
const configureSessionStart = now();
|
|
2910
2942
|
try {
|
|
2943
|
+
await applySessionConfigOptions({
|
|
2944
|
+
runtime,
|
|
2945
|
+
handle: sessionHandle,
|
|
2946
|
+
prepared,
|
|
2947
|
+
onLog: ctx.onLog,
|
|
2948
|
+
});
|
|
2949
|
+
await emitPhase("configure_session", configureSessionStart, "ok");
|
|
2950
|
+
}
|
|
2951
|
+
catch (err) {
|
|
2952
|
+
// Record a direct close that drops the matching warm entry for the
|
|
2953
|
+
// settlement `endSession` step. The settlement stops the bridges, discards
|
|
2954
|
+
// the staged runtime, releases the staging lease, and flushes the child
|
|
2955
|
+
// stderr on this exit path too.
|
|
2956
|
+
runtimeSettlement = {
|
|
2957
|
+
mode: "direct",
|
|
2958
|
+
handle: sessionHandle,
|
|
2959
|
+
reason: "paperclip config cleanup",
|
|
2960
|
+
discardPersistentState: false,
|
|
2961
|
+
dropWarmEntry: true,
|
|
2962
|
+
recordCloseError: true,
|
|
2963
|
+
cancelTurnReason: null,
|
|
2964
|
+
};
|
|
2965
|
+
await emitPhase("configure_session", configureSessionStart, "failed");
|
|
2911
2966
|
const { classified, message } = await emitAcpxFailure({
|
|
2912
2967
|
ctx,
|
|
2913
2968
|
prepared,
|
|
2914
2969
|
err,
|
|
2915
2970
|
phase: "configure_session",
|
|
2916
2971
|
});
|
|
2917
|
-
|
|
2972
|
+
capturedResult = {
|
|
2918
2973
|
exitCode: 1,
|
|
2919
2974
|
signal: null,
|
|
2920
2975
|
timedOut: false,
|
|
@@ -2933,305 +2988,386 @@ export function createAcpxEngineExecutor(deps = {}) {
|
|
|
2933
2988
|
},
|
|
2934
2989
|
summary: message,
|
|
2935
2990
|
};
|
|
2991
|
+
return settleFor("session_configuration", err);
|
|
2936
2992
|
}
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
const textParts = [];
|
|
2946
|
-
let eventBreakdown = null;
|
|
2947
|
-
let eventCostUsd = null;
|
|
2948
|
-
// `turnStarted` separates a pre-turn preparation failure (prompt build or
|
|
2949
|
-
// metadata emit) from a failure of the running turn, so the turn catch below
|
|
2950
|
-
// reports the right phase and settles the runtime on both.
|
|
2951
|
-
let turnStarted = false;
|
|
2952
|
-
// Open the agent turn span as a child of the run root span. It wraps the
|
|
2953
|
-
// whole turn: the executor holds `turnSpan.parentContext` for later exec
|
|
2954
|
-
// parenting, and the `finally` below ends the span once on every path. The
|
|
2955
|
-
// span is declared before the `try` so the `finally` can reach it.
|
|
2956
|
-
const turnSpan = openTurnSpan(tracing, now, runRootSpan.parentContext);
|
|
2957
|
-
// Switch the current-run holder to the `agent.turn` token for the turn, so
|
|
2958
|
-
// a detached exec during the turn parents to `agent.turn`. The turn
|
|
2959
|
-
// `finally` resets the holder to the `task.run` token.
|
|
2960
|
-
currentRunParentContext = turnSpan.parentContext;
|
|
2961
|
-
try {
|
|
2962
|
-
// Build the prompt and emit the run metadata inside the turn failure
|
|
2963
|
-
// boundary. A failure here settles the runtime through the turn catch and
|
|
2964
|
-
// returns an error result with phase `prepare_turn`.
|
|
2965
|
-
const { prompt, promptMetrics, commandNotes } = await buildPrompt(ctx, resumedSession, prepared.env);
|
|
2966
|
-
const runPrompt = joinPromptSections([prepared.skillPromptInstructions, prompt]);
|
|
2967
|
-
await emitAcpxLog(ctx, {
|
|
2968
|
-
type: "acpx.session",
|
|
2969
|
-
agent: prepared.acpxAgent,
|
|
2970
|
-
sessionId: sessionHandle.backendSessionId,
|
|
2971
|
-
acpSessionId: sessionHandle.backendSessionId,
|
|
2972
|
-
agentSessionId: sessionHandle.agentSessionId,
|
|
2973
|
-
runtimeSessionName: sessionHandle.runtimeSessionName,
|
|
2974
|
-
mode: prepared.mode,
|
|
2975
|
-
permissionMode: prepared.permissionMode,
|
|
2976
|
-
model: prepared.requestedModel || null,
|
|
2977
|
-
thinkingEffort: prepared.requestedThinkingEffort || null,
|
|
2978
|
-
fastMode: prepared.fastMode,
|
|
2979
|
-
});
|
|
2980
|
-
if (ctx.onMeta) {
|
|
2981
|
-
await ctx.onMeta({
|
|
2982
|
-
adapterType: engine.adapterType,
|
|
2983
|
-
command: prepared.agentCommand ?? prepared.acpxAgent,
|
|
2984
|
-
cwd: prepared.cwd,
|
|
2985
|
-
commandNotes: [
|
|
2986
|
-
`ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`,
|
|
2987
|
-
`Effective ACPX permission mode: ${prepared.permissionMode}.`,
|
|
2988
|
-
...(prepared.requestedModel
|
|
2989
|
-
? [
|
|
2990
|
-
prepared.acpxAgent === "claude"
|
|
2991
|
-
? `Requested ACPX model: ${prepared.requestedModel} (set via ANTHROPIC_MODEL env at startup).`
|
|
2992
|
-
: prepared.acpxAgent === "codex"
|
|
2993
|
-
? `Requested ACPX model: ${prepared.requestedModel} (set via CODEX_CONFIG at startup).`
|
|
2994
|
-
: `Requested ACPX model: ${prepared.requestedModel}.`,
|
|
2995
|
-
]
|
|
2996
|
-
: []),
|
|
2997
|
-
...(prepared.requestedThinkingEffort ? [`Requested ACPX thinking effort: ${prepared.requestedThinkingEffort}.`] : []),
|
|
2998
|
-
...(prepared.fastMode ? ["Requested ACPX Codex fast mode."] : []),
|
|
2999
|
-
...(Array.isArray(prepared.skillsIdentity.commandNotes)
|
|
3000
|
-
? prepared.skillsIdentity.commandNotes.filter((note) => typeof note === "string")
|
|
3001
|
-
: []),
|
|
3002
|
-
...commandNotes,
|
|
3003
|
-
],
|
|
3004
|
-
env: prepared.loggedEnv,
|
|
3005
|
-
prompt: runPrompt,
|
|
3006
|
-
promptMetrics,
|
|
3007
|
-
context: ctx.context,
|
|
3008
|
-
});
|
|
3009
|
-
}
|
|
3010
|
-
// Snapshot pre-turn usage so cumulative agent-reported cost can be
|
|
3011
|
-
// attributed to this run alone.
|
|
3012
|
-
const preTurnStatus = await readRuntimeStatus(runtime, sessionHandle);
|
|
3013
|
-
const timeoutMs = prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined;
|
|
3014
|
-
controller = new AbortController();
|
|
3015
|
-
if (timeoutMs) {
|
|
3016
|
-
timeout = setTimeout(() => {
|
|
3017
|
-
timedOut = true;
|
|
3018
|
-
controller?.abort();
|
|
3019
|
-
void cancelActiveTurn?.(formatAdapterExecutionTimeoutErrorMessage(prepared.timeoutResolution)).catch(() => { });
|
|
3020
|
-
}, timeoutMs);
|
|
3021
|
-
}
|
|
3022
|
-
const turn = runtime.startTurn({
|
|
3023
|
-
handle: sessionHandle,
|
|
3024
|
-
text: runPrompt,
|
|
3025
|
-
mode: "prompt",
|
|
3026
|
-
requestId: ctx.runId,
|
|
3027
|
-
timeoutMs,
|
|
3028
|
-
signal: controller?.signal,
|
|
3029
|
-
});
|
|
3030
|
-
turnStarted = true;
|
|
3031
|
-
cancelActiveTurn = async (reason) => {
|
|
3032
|
-
await turn.cancel({ reason });
|
|
2993
|
+
// Startup succeeded: seal the ledger (promotes the startup_rollback entries
|
|
2994
|
+
// that survived to `per_run`) and hand the ready resources to the turn. The
|
|
2995
|
+
// turn wrapper reads the run state through the shared closure locals in this
|
|
2996
|
+
// phase, so the sealed view and the context are the contract carriers only.
|
|
2997
|
+
return {
|
|
2998
|
+
kind: "ready",
|
|
2999
|
+
resources: runResourceLedger.seal([]),
|
|
3000
|
+
context: { sessionKey: prepared.sessionKey },
|
|
3033
3001
|
};
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3002
|
+
};
|
|
3003
|
+
// The turn step: run the turn sequence and settle the runtime inline
|
|
3004
|
+
// (today's behavior). The sequence owns the wall-clock timer and the abort
|
|
3005
|
+
// controller and never rejects; it returns a `TurnCompletion`. The step
|
|
3006
|
+
// bodies below record the external result for the coordinator to reproduce.
|
|
3007
|
+
const runTurn = async (_ready) => {
|
|
3008
|
+
// Summary accumulation collects output text only (never thought stream),
|
|
3009
|
+
// segmented on tool starts so multi-step narration is not glued into one
|
|
3010
|
+
// automatic comment dump.
|
|
3011
|
+
const outputSegments = [];
|
|
3012
|
+
let currentOutputChunk = [];
|
|
3013
|
+
const flushOutputSegment = () => {
|
|
3014
|
+
if (currentOutputChunk.length === 0)
|
|
3015
|
+
return;
|
|
3016
|
+
outputSegments.push(currentOutputChunk.join(""));
|
|
3017
|
+
currentOutputChunk = [];
|
|
3018
|
+
};
|
|
3019
|
+
let eventBreakdown = null;
|
|
3020
|
+
let eventCostUsd = null;
|
|
3021
|
+
// The turn-local state the sequence steps share. `promptBuild` sets the
|
|
3022
|
+
// prompt, `preTurnUsage` sets the pre-turn status, `turnStart` sets the
|
|
3023
|
+
// active turn, and `turnFinalize` reads all three. `activeTurn` is the run-
|
|
3024
|
+
// scoped hoisted local, so the settlement `endSession` step can cancel it.
|
|
3025
|
+
let runPrompt = "";
|
|
3026
|
+
let preTurnStatus = null;
|
|
3027
|
+
// Phase-timing markers for the prepare_turn and turn phases. The prepare
|
|
3028
|
+
// phase covers the prompt build and the pre-turn usage snapshot; the turn
|
|
3029
|
+
// phase covers the started turn and the event relay.
|
|
3030
|
+
let preparePhaseStart = now();
|
|
3031
|
+
let turnPhaseStart = now();
|
|
3032
|
+
// Open the agent turn span as a child of the run root span. It wraps the
|
|
3033
|
+
// whole turn: the executor holds `turnSpan.parentContext` for later exec
|
|
3034
|
+
// parenting, and the `finally` below ends the span once on every path. The
|
|
3035
|
+
// span is declared before the `try` so the `finally` can reach it.
|
|
3036
|
+
const turnSpan = openTurnSpan(tracing, now, runRootSpan.parentContext);
|
|
3037
|
+
// Switch the current-run holder to the `agent.turn` token for the turn, so
|
|
3038
|
+
// a detached exec during the turn parents to `agent.turn`. The turn
|
|
3039
|
+
// `finally` resets the holder to the `task.run` token.
|
|
3040
|
+
currentRunParentContext = turnSpan.parentContext;
|
|
3041
|
+
const stepPromptBuild = async (_signal) => {
|
|
3042
|
+
// Build the prompt and emit the run metadata inside the turn failure
|
|
3043
|
+
// boundary. A failure here returns an error result with phase
|
|
3044
|
+
// `prepare_turn`.
|
|
3045
|
+
preparePhaseStart = now();
|
|
3046
|
+
const { prompt, promptMetrics, commandNotes } = await buildPrompt(ctx, resumedSession, prepared.env);
|
|
3047
|
+
runPrompt = joinPromptSections([prepared.skillPromptInstructions, prompt]);
|
|
3048
|
+
await emitAcpxLog(ctx, {
|
|
3049
|
+
type: "acpx.session",
|
|
3050
|
+
agent: prepared.acpxAgent,
|
|
3051
|
+
sessionId: sessionHandle.backendSessionId,
|
|
3052
|
+
acpSessionId: sessionHandle.backendSessionId,
|
|
3053
|
+
agentSessionId: sessionHandle.agentSessionId,
|
|
3054
|
+
runtimeSessionName: sessionHandle.runtimeSessionName,
|
|
3055
|
+
mode: prepared.mode,
|
|
3056
|
+
permissionMode: prepared.permissionMode,
|
|
3057
|
+
model: prepared.requestedModel || null,
|
|
3058
|
+
thinkingEffort: prepared.requestedThinkingEffort || null,
|
|
3059
|
+
fastMode: prepared.fastMode,
|
|
3060
|
+
});
|
|
3061
|
+
if (ctx.onMeta) {
|
|
3062
|
+
await ctx.onMeta({
|
|
3063
|
+
adapterType: engine.adapterType,
|
|
3064
|
+
command: prepared.agentCommand ?? prepared.acpxAgent,
|
|
3065
|
+
cwd: prepared.cwd,
|
|
3066
|
+
commandNotes: [
|
|
3067
|
+
`ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`,
|
|
3068
|
+
`Effective ACPX permission mode: ${prepared.permissionMode}.`,
|
|
3069
|
+
...(prepared.requestedModel
|
|
3070
|
+
? [
|
|
3071
|
+
prepared.acpxAgent === "claude"
|
|
3072
|
+
? `Requested ACPX model: ${prepared.requestedModel} (set via ANTHROPIC_MODEL env at startup).`
|
|
3073
|
+
: prepared.acpxAgent === "codex"
|
|
3074
|
+
? `Requested ACPX model: ${prepared.requestedModel} (set via CODEX_CONFIG at startup).`
|
|
3075
|
+
: `Requested ACPX model: ${prepared.requestedModel}.`,
|
|
3076
|
+
]
|
|
3077
|
+
: []),
|
|
3078
|
+
...(prepared.requestedThinkingEffort ? [`Requested ACPX thinking effort: ${prepared.requestedThinkingEffort}.`] : []),
|
|
3079
|
+
...(prepared.fastMode ? ["Requested ACPX Codex fast mode."] : []),
|
|
3080
|
+
...(Array.isArray(prepared.skillsIdentity.commandNotes)
|
|
3081
|
+
? prepared.skillsIdentity.commandNotes.filter((note) => typeof note === "string")
|
|
3082
|
+
: []),
|
|
3083
|
+
...commandNotes,
|
|
3084
|
+
],
|
|
3085
|
+
env: prepared.loggedEnv,
|
|
3086
|
+
prompt: runPrompt,
|
|
3087
|
+
promptMetrics,
|
|
3088
|
+
context: ctx.context,
|
|
3064
3089
|
});
|
|
3065
3090
|
}
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3091
|
+
};
|
|
3092
|
+
const stepPreTurnUsage = async () => {
|
|
3093
|
+
// Snapshot pre-turn usage so cumulative agent-reported cost can be
|
|
3094
|
+
// attributed to this run alone.
|
|
3095
|
+
preTurnStatus = await readRuntimeStatus(runtime, sessionHandle);
|
|
3096
|
+
// The prepare phase (prompt build + usage snapshot) finished; the turn
|
|
3097
|
+
// phase starts next.
|
|
3098
|
+
await emitPhase("prepare_turn", preparePhaseStart, "ok");
|
|
3099
|
+
turnPhaseStart = now();
|
|
3100
|
+
};
|
|
3101
|
+
const stepTurnStart = (signal, startTimeoutMs) => {
|
|
3102
|
+
const turn = runtime.startTurn({
|
|
3103
|
+
handle: sessionHandle,
|
|
3104
|
+
text: runPrompt,
|
|
3105
|
+
mode: "prompt",
|
|
3106
|
+
requestId: ctx.runId,
|
|
3107
|
+
timeoutMs: startTimeoutMs,
|
|
3108
|
+
signal,
|
|
3109
|
+
});
|
|
3110
|
+
activeTurn = turn;
|
|
3111
|
+
return {
|
|
3112
|
+
cancel: async (reason) => {
|
|
3113
|
+
await turn.cancel({ reason });
|
|
3114
|
+
},
|
|
3115
|
+
};
|
|
3116
|
+
};
|
|
3117
|
+
const stepEventRelay = async () => {
|
|
3118
|
+
const turn = activeTurn;
|
|
3119
|
+
const toolTitles = new Map();
|
|
3120
|
+
for await (const event of turn.events) {
|
|
3121
|
+
if (event.type === "text_delta" && event.stream !== "thought") {
|
|
3122
|
+
currentOutputChunk.push(event.text);
|
|
3123
|
+
}
|
|
3124
|
+
else if (event.type === "tool_call" && event.tag !== "tool_call_update") {
|
|
3125
|
+
// ACP makes tool-call status optional. The normalized event tag is
|
|
3126
|
+
// the reliable boundary between an initial call and its updates,
|
|
3127
|
+
// so a statusless initial call must still end the preceding output
|
|
3128
|
+
// segment while updates must not create extra boundaries.
|
|
3129
|
+
flushOutputSegment();
|
|
3130
|
+
}
|
|
3131
|
+
if (event.type === "status" && event.tag === "usage_update") {
|
|
3132
|
+
eventBreakdown = event.breakdown ?? eventBreakdown;
|
|
3133
|
+
eventCostUsd = usdCostAmount(event.cost) ?? eventCostUsd;
|
|
3134
|
+
}
|
|
3135
|
+
await emitRuntimeEvent(ctx, event, toolTitles, prepared.coalescePlaceholderToolUpdates);
|
|
3082
3136
|
}
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3137
|
+
flushOutputSegment();
|
|
3138
|
+
return await turn.result;
|
|
3139
|
+
};
|
|
3140
|
+
const stepTurnFinalize = async (input) => {
|
|
3141
|
+
if (input.kind === "terminal") {
|
|
3142
|
+
const terminal = input.terminal;
|
|
3143
|
+
const timedOut = input.timedOut;
|
|
3144
|
+
// Read the sandbox duplex control-channel disposition at the ACP
|
|
3145
|
+
// terminal-finalization boundary, before the bridge teardown. A control
|
|
3146
|
+
// channel that died mid-turn latches a failure with a typed loss reason;
|
|
3147
|
+
// a healthy channel or a normal-teardown loss reports a success. Only a
|
|
3148
|
+
// nominally completed, non-timed-out terminal is success-eligible, so the
|
|
3149
|
+
// seam reads the disposition only there. For that success-eligible
|
|
3150
|
+
// terminal the seam marks the host-observed orderly completion, so a later
|
|
3151
|
+
// teardown loss cannot flip the run to a failure. The file bridge path
|
|
3152
|
+
// never sets these methods, so the optional calls no-op there.
|
|
3153
|
+
let duplexLossReason = null;
|
|
3154
|
+
if (terminal.status === "completed" && !timedOut) {
|
|
3155
|
+
// Success-eligible terminal. Atomically read the disposition and mark
|
|
3156
|
+
// the orderly completion in one broker step. No `await` separates the
|
|
3157
|
+
// read from the mark, so a teardown loss cannot slip in between them. A
|
|
3158
|
+
// latched loss fails the run closed; a healthy channel marks its
|
|
3159
|
+
// orderly completion, so a later teardown loss stays a normal teardown.
|
|
3160
|
+
const disposition = prepared.paperclipBridge?.settleRunDisposition?.() ?? null;
|
|
3161
|
+
if (disposition?.failed) {
|
|
3162
|
+
duplexLossReason = disposition.lossReason ?? "other";
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
else {
|
|
3166
|
+
// Non-success-eligible terminal (failed, cancelled, or timed out). A
|
|
3167
|
+
// deliberate host teardown follows, so mark the orderly completion now.
|
|
3168
|
+
// This stops the teardown `channel_exit` from latching `lossSeq`, from
|
|
3169
|
+
// emitting a false loss event, and from incrementing the loss counters.
|
|
3170
|
+
// The mark no-ops once a loss latched, so a real mid-run loss still
|
|
3171
|
+
// fails the run.
|
|
3172
|
+
prepared.paperclipBridge?.markOrderlyCompletion?.();
|
|
3173
|
+
}
|
|
3174
|
+
// A terminal that reports "completed" but whose duplex control channel
|
|
3175
|
+
// died before the completion is not a success. The seam fails it closed.
|
|
3176
|
+
const channelLost = duplexLossReason !== null;
|
|
3177
|
+
// Build the boundary failure message only from the closed loss-reason
|
|
3178
|
+
// enum, so no raw provider text rides the message.
|
|
3179
|
+
const channelLostMessage = duplexLossReason
|
|
3180
|
+
? `The sandbox duplex control channel was lost (${duplexLossReason}) before the run completed.`
|
|
3181
|
+
: null;
|
|
3182
|
+
// A completed, non-timed-out turn whose channel stayed live is the one
|
|
3183
|
+
// success path. Every other outcome — a failed, cancelled, or timed-out
|
|
3184
|
+
// terminal, or a completed terminal with a lost channel — is a failure.
|
|
3185
|
+
const turnSucceeded = terminal.status === "completed" && !timedOut && !channelLost;
|
|
3186
|
+
// Read usage before the settlement can discard runtime state.
|
|
3187
|
+
const postTurnStatus = await readRuntimeStatus(runtime, sessionHandle);
|
|
3188
|
+
const turnUsage = summarizeAcpxTurnUsage({
|
|
3189
|
+
preStatus: preTurnStatus,
|
|
3190
|
+
postStatus: postTurnStatus,
|
|
3191
|
+
eventBreakdown,
|
|
3192
|
+
eventCostUsd,
|
|
3193
|
+
});
|
|
3194
|
+
const failedTurn = terminal.status === "failed" || terminal.status === "cancelled" || timedOut;
|
|
3195
|
+
// Record how the settlement `endSession` step closes the runtime for this
|
|
3196
|
+
// outcome. A clean persistent host turn is save-eligible, but the
|
|
3197
|
+
// Amendment B credential gate fails on the host lane (the run API key is
|
|
3198
|
+
// never revoked), so the reuse decision discards and `endSession` closes it
|
|
3199
|
+
// (close-and-relaunch). The sandbox lane always closes the runtime; its
|
|
3200
|
+
// staged files are the reuse. A matching warm entry closes through the warm
|
|
3201
|
+
// store (which also flushes its stderr); otherwise the runtime closes
|
|
3202
|
+
// directly. The settlement then stops the bridges, saves or discards the
|
|
3203
|
+
// staged runtime, releases the staging lease, and flushes the child stderr.
|
|
3204
|
+
runtimeSettlement = {
|
|
3205
|
+
mode: "warm_or_close",
|
|
3086
3206
|
handle: sessionHandle,
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3207
|
+
reason: timedOut
|
|
3208
|
+
? "paperclip timeout cleanup"
|
|
3209
|
+
: channelLost
|
|
3210
|
+
? "paperclip duplex channel lost cleanup"
|
|
3211
|
+
: failedTurn
|
|
3212
|
+
? `paperclip turn ${terminal.status}`
|
|
3213
|
+
: "paperclip completed turn cleanup",
|
|
3214
|
+
discardPersistentState: terminal.status === "cancelled" || timedOut || channelLost,
|
|
3215
|
+
dropWarmEntry: false,
|
|
3216
|
+
recordCloseError: false,
|
|
3217
|
+
cancelTurnReason: null,
|
|
3091
3218
|
};
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3219
|
+
const errorMessage = timedOut
|
|
3220
|
+
? formatAdapterExecutionTimeoutErrorMessage(prepared.timeoutResolution)
|
|
3221
|
+
: channelLost
|
|
3222
|
+
? channelLostMessage
|
|
3223
|
+
: resultErrorMessage(terminal);
|
|
3224
|
+
const terminalStopReason = terminal.status === "failed" ? terminal.error.message : terminal.stopReason;
|
|
3225
|
+
await emitAcpxLog(ctx, {
|
|
3226
|
+
type: turnSucceeded ? "acpx.result" : "acpx.error",
|
|
3227
|
+
summary: channelLost ? "duplex_channel_lost" : terminal.status,
|
|
3228
|
+
stopReason: terminalStopReason,
|
|
3229
|
+
message: errorMessage,
|
|
3099
3230
|
});
|
|
3231
|
+
// The one clean-completion path clears the run failure flag; every other
|
|
3232
|
+
// path keeps it set, so the run root span closes with error status. A
|
|
3233
|
+
// completed terminal with a lost duplex channel keeps the flag set.
|
|
3234
|
+
runFailed = turnSucceeded ? false : true;
|
|
3235
|
+
capturedResult = {
|
|
3236
|
+
exitCode: turnSucceeded ? 0 : 1,
|
|
3237
|
+
signal: timedOut ? "SIGTERM" : null,
|
|
3238
|
+
timedOut,
|
|
3239
|
+
errorMessage,
|
|
3240
|
+
errorCode: terminal.status === "failed"
|
|
3241
|
+
? "acpx_turn_failed"
|
|
3242
|
+
: timedOut
|
|
3243
|
+
? "acpx_timeout"
|
|
3244
|
+
: channelLost
|
|
3245
|
+
? DUPLEX_CHANNEL_LOST_ERROR_CODE
|
|
3246
|
+
: null,
|
|
3247
|
+
sessionId: sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
|
|
3248
|
+
sessionParams: buildSessionParams({ prepared, handle: sessionHandle }),
|
|
3249
|
+
sessionDisplayId: sessionHandle.agentSessionId ?? sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
|
|
3250
|
+
...billingFields,
|
|
3251
|
+
...referencedProjectStagingFailuresField,
|
|
3252
|
+
model: prepared.requestedModel || null,
|
|
3253
|
+
...(turnUsage.usage ? { usage: turnUsage.usage, usageBasis: "per_run" } : {}),
|
|
3254
|
+
costUsd: turnUsage.costUsd,
|
|
3255
|
+
resultJson: {
|
|
3256
|
+
status: channelLost ? "failed" : terminal.status,
|
|
3257
|
+
stopReason: terminalStopReason,
|
|
3258
|
+
permissionMode: prepared.permissionMode,
|
|
3259
|
+
mode: prepared.mode,
|
|
3260
|
+
requestedModel: prepared.requestedModel || null,
|
|
3261
|
+
requestedThinkingEffort: prepared.requestedThinkingEffort || null,
|
|
3262
|
+
fastMode: prepared.fastMode,
|
|
3263
|
+
...(turnUsage.usageDetail ? { usage: turnUsage.usageDetail } : {}),
|
|
3264
|
+
...(turnUsage.cumulativeCostUsd != null
|
|
3265
|
+
? { cumulativeCostUsd: turnUsage.cumulativeCostUsd }
|
|
3266
|
+
: {}),
|
|
3267
|
+
},
|
|
3268
|
+
summary: buildAcpxRunSummary({
|
|
3269
|
+
outputSegments,
|
|
3270
|
+
fallback: terminalStopReason || terminal.status,
|
|
3271
|
+
}),
|
|
3272
|
+
clearSession,
|
|
3273
|
+
};
|
|
3274
|
+
// The turn phase finished. A completed, non-timed-out turn with a live
|
|
3275
|
+
// duplex channel is `ok`; every other terminal outcome is `failed`.
|
|
3276
|
+
await emitPhase("turn", turnPhaseStart, turnSucceeded ? "ok" : "failed");
|
|
3277
|
+
// Return the typed turn completion so the coordinator settles for the right
|
|
3278
|
+
// cause. The completion carries no live resources; the settlement claims the
|
|
3279
|
+
// ledger and owns the release. A clean completed turn carries no cause, so
|
|
3280
|
+
// the reuse decision can permit a save; a timed-out, failed, or cancelled
|
|
3281
|
+
// turn carries its cause, which forbids the save.
|
|
3282
|
+
if (timedOut) {
|
|
3283
|
+
return {
|
|
3284
|
+
kind: "timed_out",
|
|
3285
|
+
cause: { kind: "turn_timed_out", timeoutSec: prepared.timeoutSec },
|
|
3286
|
+
resources: emptyConsumed,
|
|
3287
|
+
};
|
|
3288
|
+
}
|
|
3289
|
+
if (terminal.status === "failed") {
|
|
3290
|
+
return {
|
|
3291
|
+
kind: "failed",
|
|
3292
|
+
cause: {
|
|
3293
|
+
kind: "turn_failed",
|
|
3294
|
+
error: terminal.error instanceof Error ? terminal.error : new Error(String(terminal.error)),
|
|
3295
|
+
},
|
|
3296
|
+
resources: emptyConsumed,
|
|
3297
|
+
};
|
|
3298
|
+
}
|
|
3299
|
+
if (terminal.status === "cancelled") {
|
|
3300
|
+
return {
|
|
3301
|
+
kind: "cancelled",
|
|
3302
|
+
cause: { kind: "turn_cancelled", reason: terminal.stopReason ?? "cancelled" },
|
|
3303
|
+
resources: emptyConsumed,
|
|
3304
|
+
};
|
|
3305
|
+
}
|
|
3306
|
+
// A completed terminal whose duplex control channel died mid-turn returns
|
|
3307
|
+
// a failed completion, so the coordinator settles for a failure and the
|
|
3308
|
+
// reuse decision forbids a save. The message carries only the typed loss
|
|
3309
|
+
// reason, so no raw provider text rides the cause.
|
|
3310
|
+
if (channelLost) {
|
|
3311
|
+
return {
|
|
3312
|
+
kind: "failed",
|
|
3313
|
+
cause: {
|
|
3314
|
+
kind: "turn_failed",
|
|
3315
|
+
error: new Error(channelLostMessage ?? "The sandbox duplex control channel was lost."),
|
|
3316
|
+
},
|
|
3317
|
+
resources: emptyConsumed,
|
|
3318
|
+
};
|
|
3319
|
+
}
|
|
3320
|
+
return { kind: "finalized" };
|
|
3100
3321
|
}
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
});
|
|
3322
|
+
const err = input.error;
|
|
3323
|
+
const timedOut = input.timedOut;
|
|
3324
|
+
// The failure phase comes from the sequence: a failure before the turn
|
|
3325
|
+
// started is `prepare_turn`; a failure after it is `turn`. The teardown is
|
|
3326
|
+
// the same; only the reported phase differs.
|
|
3327
|
+
const phase = input.phase;
|
|
3328
|
+
// Emit the failed phase timing for the phase the sequence reported.
|
|
3329
|
+
if (phase === "prepare_turn") {
|
|
3330
|
+
await emitPhase("prepare_turn", preparePhaseStart, "failed");
|
|
3111
3331
|
}
|
|
3112
3332
|
else {
|
|
3113
|
-
await
|
|
3114
|
-
handle: sessionHandle,
|
|
3115
|
-
reason: "paperclip completed turn cleanup",
|
|
3116
|
-
discardPersistentState: false,
|
|
3117
|
-
}).catch(() => { });
|
|
3333
|
+
await emitPhase("turn", turnPhaseStart, "failed");
|
|
3118
3334
|
}
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
await discardStagedRuntime({ handles: stagedRuntimes, prepared });
|
|
3130
|
-
}
|
|
3131
|
-
// The turn path has closed the runtime and settled the staged runtime. Mark
|
|
3132
|
-
// it done so a result-mapping throw below runs the turn teardown once, not a
|
|
3133
|
-
// second time through the catch.
|
|
3134
|
-
turnTeardownDone = true;
|
|
3135
|
-
const errorMessage = timedOut
|
|
3136
|
-
? formatAdapterExecutionTimeoutErrorMessage(prepared.timeoutResolution)
|
|
3137
|
-
: resultErrorMessage(terminal);
|
|
3138
|
-
const terminalStopReason = terminal.status === "failed" ? terminal.error.message : terminal.stopReason;
|
|
3139
|
-
await emitAcpxLog(ctx, {
|
|
3140
|
-
type: terminal.status === "completed" ? "acpx.result" : "acpx.error",
|
|
3141
|
-
summary: terminal.status,
|
|
3142
|
-
stopReason: terminalStopReason,
|
|
3143
|
-
message: errorMessage,
|
|
3144
|
-
});
|
|
3145
|
-
// Settle the run resources once: stop the bridges, run the copy-back,
|
|
3146
|
-
// release the staging lease (in a finally inside cleanupRemoteBridges), and
|
|
3147
|
-
// flush the child stderr. Mark them settled first so a result-mapping throw
|
|
3148
|
-
// below does not run the teardown again through the turn catch.
|
|
3149
|
-
runResourcesSettled = true;
|
|
3150
|
-
await cleanupRemoteBridges(prepared);
|
|
3151
|
-
flushChildStderr(childStderrState);
|
|
3152
|
-
// The one clean-completion path clears the run failure flag; every other
|
|
3153
|
-
// path keeps it set, so the run root span closes with error status.
|
|
3154
|
-
runFailed = terminal.status === "completed" && !timedOut ? false : true;
|
|
3155
|
-
return {
|
|
3156
|
-
exitCode: terminal.status === "completed" ? 0 : 1,
|
|
3157
|
-
signal: timedOut ? "SIGTERM" : null,
|
|
3158
|
-
timedOut,
|
|
3159
|
-
errorMessage,
|
|
3160
|
-
errorCode: terminal.status === "failed" ? "acpx_turn_failed" : timedOut ? "acpx_timeout" : null,
|
|
3161
|
-
sessionId: sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
|
|
3162
|
-
sessionParams: buildSessionParams({ prepared, handle: sessionHandle }),
|
|
3163
|
-
sessionDisplayId: sessionHandle.agentSessionId ?? sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
|
|
3164
|
-
...billingFields,
|
|
3165
|
-
...referencedProjectStagingFailuresField,
|
|
3166
|
-
model: prepared.requestedModel || null,
|
|
3167
|
-
...(turnUsage.usage ? { usage: turnUsage.usage, usageBasis: "per_run" } : {}),
|
|
3168
|
-
costUsd: turnUsage.costUsd,
|
|
3169
|
-
resultJson: {
|
|
3170
|
-
status: terminal.status,
|
|
3171
|
-
stopReason: terminalStopReason,
|
|
3172
|
-
permissionMode: prepared.permissionMode,
|
|
3173
|
-
mode: prepared.mode,
|
|
3174
|
-
requestedModel: prepared.requestedModel || null,
|
|
3175
|
-
requestedThinkingEffort: prepared.requestedThinkingEffort || null,
|
|
3176
|
-
fastMode: prepared.fastMode,
|
|
3177
|
-
...(turnUsage.usageDetail ? { usage: turnUsage.usageDetail } : {}),
|
|
3178
|
-
...(turnUsage.cumulativeCostUsd != null
|
|
3179
|
-
? { cumulativeCostUsd: turnUsage.cumulativeCostUsd }
|
|
3180
|
-
: {}),
|
|
3181
|
-
},
|
|
3182
|
-
summary: textParts.join("").trim() || terminalStopReason || terminal.status,
|
|
3183
|
-
clearSession,
|
|
3184
|
-
};
|
|
3185
|
-
}
|
|
3186
|
-
catch (err) {
|
|
3187
|
-
if (timeout)
|
|
3188
|
-
clearTimeout(timeout);
|
|
3189
|
-
// A failure before `startTurn` returned is a turn-preparation failure; a
|
|
3190
|
-
// failure after it is a running-turn failure. The teardown is the same;
|
|
3191
|
-
// only the reported phase differs.
|
|
3192
|
-
const phase = turnStarted ? "turn" : "prepare_turn";
|
|
3193
|
-
const messageOverride = timedOut
|
|
3194
|
-
? formatAdapterExecutionTimeoutErrorMessage(prepared.timeoutResolution)
|
|
3195
|
-
: undefined;
|
|
3196
|
-
const cancel = cancelActiveTurn;
|
|
3197
|
-
const preEmitMessage = messageOverride ?? (err instanceof Error ? err.message : String(err));
|
|
3198
|
-
// Skip the cancel, close, and staged-runtime drop when the success path
|
|
3199
|
-
// already ran them: a result-mapping throw after the close reaches this
|
|
3200
|
-
// catch, and the turn teardown must not run twice. The close records its
|
|
3201
|
-
// error and never blocks the later teardown; `settleRunResources` in the
|
|
3202
|
-
// finally stops the bridges, releases the staging lease, and flushes the
|
|
3203
|
-
// child stderr even if the emission throws.
|
|
3204
|
-
if (!turnTeardownDone) {
|
|
3205
|
-
turnTeardownDone = true;
|
|
3206
|
-
if (cancel)
|
|
3207
|
-
await cancel(preEmitMessage).catch(() => { });
|
|
3208
|
-
await runtime.close({
|
|
3335
|
+
const messageOverride = timedOut
|
|
3336
|
+
? formatAdapterExecutionTimeoutErrorMessage(prepared.timeoutResolution)
|
|
3337
|
+
: undefined;
|
|
3338
|
+
const preEmitMessage = messageOverride ?? (err instanceof Error ? err.message : String(err));
|
|
3339
|
+
// Record a direct close for the settlement `endSession` step: cancel the
|
|
3340
|
+
// running turn first (cancel-before-close), then close the runtime and drop
|
|
3341
|
+
// a matching warm entry. The settlement discards the staged runtime, stops
|
|
3342
|
+
// the bridges, releases the staging lease, and flushes the child stderr.
|
|
3343
|
+
runtimeSettlement = {
|
|
3344
|
+
mode: "direct",
|
|
3209
3345
|
handle: sessionHandle,
|
|
3210
3346
|
reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup",
|
|
3211
3347
|
discardPersistentState: timedOut,
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3348
|
+
dropWarmEntry: true,
|
|
3349
|
+
recordCloseError: true,
|
|
3350
|
+
cancelTurnReason: preEmitMessage,
|
|
3351
|
+
};
|
|
3352
|
+
// Emit the failure best-effort. `turnFinalize` must not reject, so a
|
|
3353
|
+
// failing emission never propagates: the settlement owns the teardown, and
|
|
3354
|
+
// it runs only after this returns a completion. On an emission failure the
|
|
3355
|
+
// run records a degraded result from the pre-emit message.
|
|
3356
|
+
let emitted = null;
|
|
3357
|
+
try {
|
|
3358
|
+
emitted = await emitAcpxFailure({ ctx, prepared, err, phase, messageOverride });
|
|
3217
3359
|
}
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
const
|
|
3222
|
-
|
|
3223
|
-
prepared,
|
|
3224
|
-
err,
|
|
3225
|
-
phase,
|
|
3226
|
-
messageOverride,
|
|
3227
|
-
});
|
|
3228
|
-
return {
|
|
3360
|
+
catch {
|
|
3361
|
+
emitted = null;
|
|
3362
|
+
}
|
|
3363
|
+
const message = emitted?.message ?? preEmitMessage;
|
|
3364
|
+
capturedResult = {
|
|
3229
3365
|
exitCode: 1,
|
|
3230
3366
|
signal: timedOut ? "SIGTERM" : null,
|
|
3231
3367
|
timedOut,
|
|
3232
3368
|
errorMessage: message,
|
|
3233
|
-
errorCode: timedOut ? "acpx_timeout" : classified.errorCode,
|
|
3234
|
-
errorMeta: classified.errorMeta,
|
|
3369
|
+
errorCode: timedOut ? "acpx_timeout" : (emitted?.classified.errorCode ?? null),
|
|
3370
|
+
errorMeta: emitted?.classified.errorMeta,
|
|
3235
3371
|
...billingFields,
|
|
3236
3372
|
...referencedProjectStagingFailuresField,
|
|
3237
3373
|
model: prepared.requestedModel || null,
|
|
@@ -3239,25 +3375,222 @@ export function createAcpxEngineExecutor(deps = {}) {
|
|
|
3239
3375
|
resultJson: { phase },
|
|
3240
3376
|
summary: message,
|
|
3241
3377
|
};
|
|
3378
|
+
// Return a typed failed completion so the coordinator settles for a cause
|
|
3379
|
+
// that forbids the save. The reported phase lives on the recorded result.
|
|
3380
|
+
return {
|
|
3381
|
+
kind: "failed",
|
|
3382
|
+
cause: { kind: "turn_failed", error: err instanceof Error ? err : new Error(String(err)) },
|
|
3383
|
+
resources: emptyConsumed,
|
|
3384
|
+
};
|
|
3385
|
+
};
|
|
3386
|
+
try {
|
|
3387
|
+
return await runTurnSequence({
|
|
3388
|
+
timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined,
|
|
3389
|
+
timeoutMessage: formatAdapterExecutionTimeoutErrorMessage(prepared.timeoutResolution),
|
|
3390
|
+
promptBuild: stepPromptBuild,
|
|
3391
|
+
preTurnUsage: stepPreTurnUsage,
|
|
3392
|
+
turnStart: stepTurnStart,
|
|
3393
|
+
eventRelay: stepEventRelay,
|
|
3394
|
+
turnFinalize: stepTurnFinalize,
|
|
3395
|
+
});
|
|
3242
3396
|
}
|
|
3243
3397
|
finally {
|
|
3244
|
-
|
|
3398
|
+
// End the agent turn span exactly once, on every return and on a throw.
|
|
3399
|
+
// `runFailed` is `false` only on a completed, non-timed-out turn, so the
|
|
3400
|
+
// span status is correct for success, error, and timeout.
|
|
3401
|
+
turnSpan.end(runFailed);
|
|
3402
|
+
// Reset the current-run holder to the `task.run` token after the turn.
|
|
3403
|
+
// The run stays live here, so the holder is never `undefined`. A detached
|
|
3404
|
+
// exec after the turn parents to `task.run`.
|
|
3405
|
+
currentRunParentContext = runRootSpan.parentContext;
|
|
3245
3406
|
}
|
|
3246
|
-
}
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3407
|
+
};
|
|
3408
|
+
// A live run-scoped credential marker. The host-lane reuse candidate carries
|
|
3409
|
+
// it, so the Amendment B credential gate blocks the host warm save: the
|
|
3410
|
+
// run-minted API key is never revoked, so it stays valid and forces a
|
|
3411
|
+
// close-and-relaunch. It is a non-secret marker; the settlement reads only its
|
|
3412
|
+
// presence, and no report or reuse payload carries it.
|
|
3413
|
+
const LIVE_RUN_SCOPED_API_KEY = "paperclip-run-scoped-api-key";
|
|
3414
|
+
// Record the final per-resource disposition report where a test can observe
|
|
3415
|
+
// it. The engine never reads it back.
|
|
3416
|
+
const recordDispositionReport = (report) => {
|
|
3417
|
+
deps.onSettlementDisposition?.(report);
|
|
3418
|
+
};
|
|
3419
|
+
// The settlement sequence is the one live cleanup owner for every settled path
|
|
3420
|
+
// (Phase 22). It claims the ledger once, makes the pure reuse decision, then
|
|
3421
|
+
// runs the ordered steps: `endSession`, `settleReuse`, `stopTransport`,
|
|
3422
|
+
// `syncBack`, and `releaseStagingLease` (in a finally). Each step reads the run
|
|
3423
|
+
// state through the shared closure locals and no-ops on an empty ledger slot.
|
|
3424
|
+
// The Phase 3 error policy governs every step. The coordinator alone owns the
|
|
3425
|
+
// startup rollback.
|
|
3426
|
+
const settlementSteps = {
|
|
3427
|
+
// Derive the reuse candidate. Only a clean turn (no cause) can save. The
|
|
3428
|
+
// host lane would save the live runtime, but the Amendment B credential gate
|
|
3429
|
+
// fails there: the run-minted API key is never revoked, so a live run-scoped
|
|
3430
|
+
// credential stays valid and the decision discards (close-and-relaunch). The
|
|
3431
|
+
// sandbox lane saves its staged files, which carry no credential.
|
|
3432
|
+
reuseCandidate: (slots, cause) => {
|
|
3433
|
+
const clean = cause === null;
|
|
3434
|
+
if (prepared.processSessionBridge) {
|
|
3435
|
+
// Sandbox lane: the staged files are the reuse. Save only after a clean
|
|
3436
|
+
// turn and only when the run holds a staged runtime.
|
|
3437
|
+
if (!clean || !slots.has("staged_runtime"))
|
|
3438
|
+
return null;
|
|
3439
|
+
return { kind: "sandbox", causePermitsSave: true, liveRunScopedCredentials: [] };
|
|
3440
|
+
}
|
|
3441
|
+
// Host lane: save a clean persistent warm-eligible turn, but carry the
|
|
3442
|
+
// live run-scoped credential so the gate blocks the transfer.
|
|
3443
|
+
if (!slots.has("acp_runtime"))
|
|
3444
|
+
return null;
|
|
3445
|
+
const permits = clean && prepared.mode === "persistent" && warmIdleMs > 0;
|
|
3446
|
+
if (!permits)
|
|
3447
|
+
return null;
|
|
3448
|
+
return { kind: "host", causePermitsSave: true, liveRunScopedCredentials: [LIVE_RUN_SCOPED_API_KEY] };
|
|
3449
|
+
},
|
|
3450
|
+
// Close every runtime the decision did not transfer, and drop the warm entry
|
|
3451
|
+
// on close.
|
|
3452
|
+
endSession: (slots, decision) => timedPhase("end_session", async () => {
|
|
3453
|
+
if (!slots.has("acp_runtime"))
|
|
3454
|
+
return;
|
|
3455
|
+
if (decision.kind === "save" && decision.savedId === "acp_runtime")
|
|
3456
|
+
return;
|
|
3457
|
+
const settlement = runtimeSettlement ?? {
|
|
3458
|
+
mode: "direct",
|
|
3459
|
+
handle: syntheticCloseHandle(),
|
|
3460
|
+
reason: "paperclip cleanup",
|
|
3461
|
+
discardPersistentState: false,
|
|
3462
|
+
dropWarmEntry: false,
|
|
3463
|
+
recordCloseError: true,
|
|
3464
|
+
cancelTurnReason: null,
|
|
3465
|
+
};
|
|
3466
|
+
// Cancel a running turn before the close (the turn-error path).
|
|
3467
|
+
if (settlement.cancelTurnReason && activeTurn) {
|
|
3468
|
+
await activeTurn.cancel({ reason: settlement.cancelTurnReason }).catch(() => { });
|
|
3469
|
+
}
|
|
3470
|
+
const existing = warmHandles.get(prepared.sessionKey);
|
|
3471
|
+
if (settlement.mode === "warm_or_close" &&
|
|
3472
|
+
warmHandleMatches(existing, runtime, settlement.handle) &&
|
|
3473
|
+
existing) {
|
|
3474
|
+
// A matching warm entry closes through the warm store, which also
|
|
3475
|
+
// clears its idle timer and flushes its child stderr.
|
|
3476
|
+
await closeWarmHandle({
|
|
3477
|
+
handles: warmHandles,
|
|
3478
|
+
key: prepared.sessionKey,
|
|
3479
|
+
entry: existing,
|
|
3480
|
+
reason: settlement.reason,
|
|
3481
|
+
discardPersistentState: settlement.discardPersistentState,
|
|
3482
|
+
});
|
|
3483
|
+
return;
|
|
3484
|
+
}
|
|
3485
|
+
const onCloseError = settlement.recordCloseError
|
|
3486
|
+
? (closeErr) => recordTeardownError("runtime-close", closeErr)
|
|
3487
|
+
: () => { };
|
|
3488
|
+
await runtime
|
|
3489
|
+
.close({
|
|
3490
|
+
handle: settlement.handle,
|
|
3491
|
+
reason: settlement.reason,
|
|
3492
|
+
discardPersistentState: settlement.discardPersistentState,
|
|
3493
|
+
})
|
|
3494
|
+
.catch(onCloseError);
|
|
3495
|
+
if (settlement.dropWarmEntry && warmHandleMatches(existing, runtime, settlement.handle) && existing) {
|
|
3496
|
+
clearWarmHandleTimer(existing);
|
|
3497
|
+
warmHandles.delete(prepared.sessionKey);
|
|
3498
|
+
}
|
|
3499
|
+
}),
|
|
3500
|
+
// Perform the reuse decision. A save transfers the staged files to the site
|
|
3501
|
+
// store (which arms the idle policy); every other case discards the staged
|
|
3502
|
+
// runtime. The host warm save is closed in `endSession`.
|
|
3503
|
+
settleReuse: (slots, decision) => timedPhase("settle_reuse", async () => {
|
|
3504
|
+
if (!slots.has("staged_runtime"))
|
|
3505
|
+
return;
|
|
3506
|
+
if (decision.kind === "save" && decision.savedId === "staged_runtime") {
|
|
3507
|
+
saveStagedRuntimeAfterCleanTurn({ handles: stagedRuntimes, prepared, now: now() });
|
|
3508
|
+
return;
|
|
3509
|
+
}
|
|
3510
|
+
await discardStagedRuntime({ handles: stagedRuntimes, prepared });
|
|
3511
|
+
}),
|
|
3512
|
+
// Stop both bridges in one allSettled.
|
|
3513
|
+
stopTransport: () => timedPhase("stop_transport", async () => {
|
|
3514
|
+
await stopRunTransport(prepared);
|
|
3515
|
+
}),
|
|
3516
|
+
// The site sync-back (the managed-home copy-back). The run-parented span
|
|
3517
|
+
// runner wraps the restore in a `sandbox.syncBack` span. The runner also
|
|
3518
|
+
// publishes the run parent into the runtime-parent store while the restore
|
|
3519
|
+
// runs, so the host mints a `traceparent` for the provider spans, and the
|
|
3520
|
+
// per-task restore spans parent to `sandbox.syncBack`.
|
|
3521
|
+
syncBack: () => timedPhase("sync_back", async () => {
|
|
3522
|
+
await runRuntimeSpan("sandbox.syncBack", async () => {
|
|
3523
|
+
await syncBackManagedHome(prepared);
|
|
3524
|
+
});
|
|
3525
|
+
}),
|
|
3526
|
+
// The staging lease releases as the run's final act, AFTER the coordinator
|
|
3527
|
+
// reproduces the result, in the run root `finally` below. This step stays a
|
|
3528
|
+
// no-op in the live engine: a same-session second run must stay blocked on
|
|
3529
|
+
// the lease until this run fully returns, not merely until the settlement
|
|
3530
|
+
// sync-back finishes, so the release cannot move earlier into settlement.
|
|
3531
|
+
releaseStagingLease: () => { },
|
|
3532
|
+
recordError: async (step, error) => {
|
|
3533
|
+
await recordTeardownError(`settlement-${step}`, error);
|
|
3534
|
+
},
|
|
3535
|
+
};
|
|
3536
|
+
// Drive the attempt through the coordinator routing table. The startup step
|
|
3537
|
+
// returns `ready` or `settle` (or throws on a build or partial-bridge
|
|
3538
|
+
// failure); the coordinator runs the turn on the ready path, settles, then
|
|
3539
|
+
// reproduces the recorded result.
|
|
3540
|
+
const plan = {
|
|
3541
|
+
ledger: runResourceLedger,
|
|
3542
|
+
startup,
|
|
3543
|
+
runTurn,
|
|
3544
|
+
// The coordinator owns the startup rollback: `buildRuntime` runs its own
|
|
3545
|
+
// partial-bring-up rollback and throws, so no ledger resource is left to
|
|
3546
|
+
// release here.
|
|
3547
|
+
rollbackStartup: () => { },
|
|
3548
|
+
recordDisposition: recordDispositionReport,
|
|
3549
|
+
// The settlement sequence is the one live cleanup owner. It claims the
|
|
3550
|
+
// ledger, releases every settled resource, and returns the final
|
|
3551
|
+
// disposition report. The child stderr flushes after the sync-back on every
|
|
3552
|
+
// settled exit path (it stays null before the run reads the warm entry).
|
|
3553
|
+
settle: async (reason) => {
|
|
3554
|
+
const cause = reason.kind === "pre_turn"
|
|
3555
|
+
? reason.cause
|
|
3556
|
+
: reason.completion.kind === "finalized"
|
|
3557
|
+
? null
|
|
3558
|
+
: reason.completion.cause;
|
|
3559
|
+
const report = await settleAcpRun(runResourceLedger, cause, settlementSteps);
|
|
3560
|
+
recordDispositionReport(report);
|
|
3561
|
+
if (childStderrState)
|
|
3562
|
+
flushChildStderr(childStderrState);
|
|
3563
|
+
},
|
|
3564
|
+
reproduceResult: () => {
|
|
3565
|
+
if (!capturedResult) {
|
|
3566
|
+
throw new Error("run coordinator reproduced a result before the run recorded one");
|
|
3567
|
+
}
|
|
3568
|
+
return capturedResult;
|
|
3569
|
+
},
|
|
3570
|
+
};
|
|
3571
|
+
return await runAttempt(plan);
|
|
3257
3572
|
}
|
|
3258
3573
|
finally {
|
|
3259
3574
|
// End the run root span exactly once, on every return and on a throw.
|
|
3260
3575
|
runRootSpan.end(runFailed);
|
|
3576
|
+
// Release the per-session staging lease as the run's final act, AFTER the
|
|
3577
|
+
// coordinator settled every other resource and reproduced the result. It runs
|
|
3578
|
+
// last, in this `finally`, so a same-session second run stays blocked on the
|
|
3579
|
+
// lease until this run fully returns (not merely until the settlement
|
|
3580
|
+
// sync-back finishes) and an earlier teardown fault never strands the lease.
|
|
3581
|
+
// It stays null on a build failure (where `buildRuntime` released its own
|
|
3582
|
+
// partial lease) and on the host lane (no staging). The settlement stopped the
|
|
3583
|
+
// bridges and ran the sync-back before this point, so the ordering is
|
|
3584
|
+
// bridge-stop → sync-back → lease release.
|
|
3585
|
+
const leaseRelease = releaseStagingLease;
|
|
3586
|
+
if (leaseRelease) {
|
|
3587
|
+
const leaseStart = now();
|
|
3588
|
+
leaseRelease();
|
|
3589
|
+
// Fire-and-forget the phase timing: the release is the run's final act, so
|
|
3590
|
+
// the run must not await telemetry here. `emitRunPhaseTiming` swallows a
|
|
3591
|
+
// sink failure, so this never rejects.
|
|
3592
|
+
void emitRunPhaseTiming(ctx, "release_staging_lease", now() - leaseStart, "ok");
|
|
3593
|
+
}
|
|
3261
3594
|
}
|
|
3262
3595
|
};
|
|
3263
3596
|
}
|