@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.
Files changed (238) hide show
  1. package/dist/acpx-engine/composed-run-characterization.test.js +12 -13
  2. package/dist/acpx-engine/composed-run-characterization.test.js.map +1 -1
  3. package/dist/acpx-engine/constants.d.ts +1 -0
  4. package/dist/acpx-engine/constants.d.ts.map +1 -1
  5. package/dist/acpx-engine/constants.js +1 -0
  6. package/dist/acpx-engine/constants.js.map +1 -1
  7. package/dist/acpx-engine/execute-identity.test.d.ts +2 -0
  8. package/dist/acpx-engine/execute-identity.test.d.ts.map +1 -0
  9. package/dist/acpx-engine/execute-identity.test.js +80 -0
  10. package/dist/acpx-engine/execute-identity.test.js.map +1 -0
  11. package/dist/acpx-engine/execute.d.ts +48 -22
  12. package/dist/acpx-engine/execute.d.ts.map +1 -1
  13. package/dist/acpx-engine/execute.js +1216 -883
  14. package/dist/acpx-engine/execute.js.map +1 -1
  15. package/dist/acpx-engine/execute.test.js +706 -37
  16. package/dist/acpx-engine/execute.test.js.map +1 -1
  17. package/dist/acpx-engine/run-contracts.d.ts +407 -0
  18. package/dist/acpx-engine/run-contracts.d.ts.map +1 -0
  19. package/dist/acpx-engine/run-contracts.js +20 -0
  20. package/dist/acpx-engine/run-contracts.js.map +1 -0
  21. package/dist/acpx-engine/run-contracts.test.d.ts +2 -0
  22. package/dist/acpx-engine/run-contracts.test.d.ts.map +1 -0
  23. package/dist/acpx-engine/run-contracts.test.js +57 -0
  24. package/dist/acpx-engine/run-contracts.test.js.map +1 -0
  25. package/dist/acpx-engine/run-coordinator.d.ts +57 -0
  26. package/dist/acpx-engine/run-coordinator.d.ts.map +1 -0
  27. package/dist/acpx-engine/run-coordinator.js +63 -0
  28. package/dist/acpx-engine/run-coordinator.js.map +1 -0
  29. package/dist/acpx-engine/run-coordinator.test.d.ts +2 -0
  30. package/dist/acpx-engine/run-coordinator.test.d.ts.map +1 -0
  31. package/dist/acpx-engine/run-coordinator.test.js +160 -0
  32. package/dist/acpx-engine/run-coordinator.test.js.map +1 -0
  33. package/dist/acpx-engine/run-fault-matrix.test.d.ts +2 -0
  34. package/dist/acpx-engine/run-fault-matrix.test.d.ts.map +1 -0
  35. package/dist/acpx-engine/run-fault-matrix.test.js +768 -0
  36. package/dist/acpx-engine/run-fault-matrix.test.js.map +1 -0
  37. package/dist/acpx-engine/run-resource-ledger.d.ts +30 -0
  38. package/dist/acpx-engine/run-resource-ledger.d.ts.map +1 -0
  39. package/dist/acpx-engine/run-resource-ledger.js +148 -0
  40. package/dist/acpx-engine/run-resource-ledger.js.map +1 -0
  41. package/dist/acpx-engine/run-resource-ledger.test.d.ts +2 -0
  42. package/dist/acpx-engine/run-resource-ledger.test.d.ts.map +1 -0
  43. package/dist/acpx-engine/run-resource-ledger.test.js +154 -0
  44. package/dist/acpx-engine/run-resource-ledger.test.js.map +1 -0
  45. package/dist/acpx-engine/run-site-host.d.ts +71 -0
  46. package/dist/acpx-engine/run-site-host.d.ts.map +1 -0
  47. package/dist/acpx-engine/run-site-host.js +65 -0
  48. package/dist/acpx-engine/run-site-host.js.map +1 -0
  49. package/dist/acpx-engine/run-site-host.test.d.ts +2 -0
  50. package/dist/acpx-engine/run-site-host.test.d.ts.map +1 -0
  51. package/dist/acpx-engine/run-site-host.test.js +98 -0
  52. package/dist/acpx-engine/run-site-host.test.js.map +1 -0
  53. package/dist/acpx-engine/run-site-sandbox.d.ts +141 -0
  54. package/dist/acpx-engine/run-site-sandbox.d.ts.map +1 -0
  55. package/dist/acpx-engine/run-site-sandbox.js +278 -0
  56. package/dist/acpx-engine/run-site-sandbox.js.map +1 -0
  57. package/dist/acpx-engine/run-site-sandbox.test.d.ts +2 -0
  58. package/dist/acpx-engine/run-site-sandbox.test.d.ts.map +1 -0
  59. package/dist/acpx-engine/run-site-sandbox.test.js +271 -0
  60. package/dist/acpx-engine/run-site-sandbox.test.js.map +1 -0
  61. package/dist/acpx-engine/session-reuse-store.d.ts +62 -0
  62. package/dist/acpx-engine/session-reuse-store.d.ts.map +1 -0
  63. package/dist/acpx-engine/session-reuse-store.js +134 -0
  64. package/dist/acpx-engine/session-reuse-store.js.map +1 -0
  65. package/dist/acpx-engine/session-reuse-store.test.d.ts +2 -0
  66. package/dist/acpx-engine/session-reuse-store.test.d.ts.map +1 -0
  67. package/dist/acpx-engine/session-reuse-store.test.js +134 -0
  68. package/dist/acpx-engine/session-reuse-store.test.js.map +1 -0
  69. package/dist/acpx-engine/settlement-characterization.test.js +24 -27
  70. package/dist/acpx-engine/settlement-characterization.test.js.map +1 -1
  71. package/dist/acpx-engine/settlement-sequence.d.ts +77 -0
  72. package/dist/acpx-engine/settlement-sequence.d.ts.map +1 -0
  73. package/dist/acpx-engine/settlement-sequence.js +119 -0
  74. package/dist/acpx-engine/settlement-sequence.js.map +1 -0
  75. package/dist/acpx-engine/settlement-sequence.test.d.ts +2 -0
  76. package/dist/acpx-engine/settlement-sequence.test.d.ts.map +1 -0
  77. package/dist/acpx-engine/settlement-sequence.test.js +272 -0
  78. package/dist/acpx-engine/settlement-sequence.test.js.map +1 -0
  79. package/dist/acpx-engine/startup-characterization.test.js +21 -17
  80. package/dist/acpx-engine/startup-characterization.test.js.map +1 -1
  81. package/dist/acpx-engine/startup-timing.d.ts +36 -1
  82. package/dist/acpx-engine/startup-timing.d.ts.map +1 -1
  83. package/dist/acpx-engine/startup-timing.js +63 -1
  84. package/dist/acpx-engine/startup-timing.js.map +1 -1
  85. package/dist/acpx-engine/startup-timing.test.js +49 -1
  86. package/dist/acpx-engine/startup-timing.test.js.map +1 -1
  87. package/dist/acpx-engine/turn-sequence.d.ts +53 -0
  88. package/dist/acpx-engine/turn-sequence.d.ts.map +1 -0
  89. package/dist/acpx-engine/turn-sequence.js +55 -0
  90. package/dist/acpx-engine/turn-sequence.js.map +1 -0
  91. package/dist/acpx-engine/turn-sequence.test.d.ts +2 -0
  92. package/dist/acpx-engine/turn-sequence.test.d.ts.map +1 -0
  93. package/dist/acpx-engine/turn-sequence.test.js +177 -0
  94. package/dist/acpx-engine/turn-sequence.test.js.map +1 -0
  95. package/dist/command-managed-runtime.d.ts +53 -0
  96. package/dist/command-managed-runtime.d.ts.map +1 -1
  97. package/dist/command-managed-runtime.js +7 -0
  98. package/dist/command-managed-runtime.js.map +1 -1
  99. package/dist/command-managed-runtime.test.js +52 -0
  100. package/dist/command-managed-runtime.test.js.map +1 -1
  101. package/dist/duplex-aggregate-byte-ledger.d.ts +236 -0
  102. package/dist/duplex-aggregate-byte-ledger.d.ts.map +1 -0
  103. package/dist/duplex-aggregate-byte-ledger.js +302 -0
  104. package/dist/duplex-aggregate-byte-ledger.js.map +1 -0
  105. package/dist/duplex-aggregate-byte-ledger.test.d.ts +2 -0
  106. package/dist/duplex-aggregate-byte-ledger.test.d.ts.map +1 -0
  107. package/dist/duplex-aggregate-byte-ledger.test.js +173 -0
  108. package/dist/duplex-aggregate-byte-ledger.test.js.map +1 -0
  109. package/dist/duplex-body-spool.d.ts +175 -0
  110. package/dist/duplex-body-spool.d.ts.map +1 -0
  111. package/dist/duplex-body-spool.js +478 -0
  112. package/dist/duplex-body-spool.js.map +1 -0
  113. package/dist/duplex-body-spool.test.d.ts +2 -0
  114. package/dist/duplex-body-spool.test.d.ts.map +1 -0
  115. package/dist/duplex-body-spool.test.js +279 -0
  116. package/dist/duplex-body-spool.test.js.map +1 -0
  117. package/dist/duplex-bridge-broker-byte-ledger.test.d.ts +2 -0
  118. package/dist/duplex-bridge-broker-byte-ledger.test.d.ts.map +1 -0
  119. package/dist/duplex-bridge-broker-byte-ledger.test.js +299 -0
  120. package/dist/duplex-bridge-broker-byte-ledger.test.js.map +1 -0
  121. package/dist/duplex-bridge-broker.d.ts +286 -0
  122. package/dist/duplex-bridge-broker.d.ts.map +1 -0
  123. package/dist/duplex-bridge-broker.js +1098 -0
  124. package/dist/duplex-bridge-broker.js.map +1 -0
  125. package/dist/duplex-bridge-broker.test.d.ts +2 -0
  126. package/dist/duplex-bridge-broker.test.d.ts.map +1 -0
  127. package/dist/duplex-bridge-broker.test.js +869 -0
  128. package/dist/duplex-bridge-broker.test.js.map +1 -0
  129. package/dist/duplex-bridge-local-e2e.test.d.ts +2 -0
  130. package/dist/duplex-bridge-local-e2e.test.d.ts.map +1 -0
  131. package/dist/duplex-bridge-local-e2e.test.js +566 -0
  132. package/dist/duplex-bridge-local-e2e.test.js.map +1 -0
  133. package/dist/duplex-frame-codec.d.ts +267 -0
  134. package/dist/duplex-frame-codec.d.ts.map +1 -0
  135. package/dist/duplex-frame-codec.js +381 -0
  136. package/dist/duplex-frame-codec.js.map +1 -0
  137. package/dist/duplex-frame-codec.test.d.ts +2 -0
  138. package/dist/duplex-frame-codec.test.d.ts.map +1 -0
  139. package/dist/duplex-frame-codec.test.js +429 -0
  140. package/dist/duplex-frame-codec.test.js.map +1 -0
  141. package/dist/duplex-observability.d.ts +216 -0
  142. package/dist/duplex-observability.d.ts.map +1 -0
  143. package/dist/duplex-observability.js +234 -0
  144. package/dist/duplex-observability.js.map +1 -0
  145. package/dist/execution-target-sandbox.test.js +3585 -10
  146. package/dist/execution-target-sandbox.test.js.map +1 -1
  147. package/dist/execution-target.d.ts +170 -1
  148. package/dist/execution-target.d.ts.map +1 -1
  149. package/dist/execution-target.js +1039 -60
  150. package/dist/execution-target.js.map +1 -1
  151. package/dist/git-workspace-sync.d.ts +32 -0
  152. package/dist/git-workspace-sync.d.ts.map +1 -1
  153. package/dist/git-workspace-sync.js +84 -1
  154. package/dist/git-workspace-sync.js.map +1 -1
  155. package/dist/git-workspace-sync.test.js +104 -1
  156. package/dist/git-workspace-sync.test.js.map +1 -1
  157. package/dist/index.d.ts +4 -0
  158. package/dist/index.d.ts.map +1 -1
  159. package/dist/index.js +2 -0
  160. package/dist/index.js.map +1 -1
  161. package/dist/login-capability.d.ts +88 -0
  162. package/dist/login-capability.d.ts.map +1 -0
  163. package/dist/login-capability.js +78 -0
  164. package/dist/login-capability.js.map +1 -0
  165. package/dist/login-capability.test.d.ts +2 -0
  166. package/dist/login-capability.test.d.ts.map +1 -0
  167. package/dist/login-capability.test.js +78 -0
  168. package/dist/login-capability.test.js.map +1 -0
  169. package/dist/{setup-token-transport.d.ts → login-pty-transport.d.ts} +6 -6
  170. package/dist/login-pty-transport.d.ts.map +1 -0
  171. package/dist/{setup-token-transport.js → login-pty-transport.js} +6 -6
  172. package/dist/login-pty-transport.js.map +1 -0
  173. package/dist/login-pty-transport.test.d.ts +2 -0
  174. package/dist/login-pty-transport.test.d.ts.map +1 -0
  175. package/dist/{setup-token-transport.test.js → login-pty-transport.test.js} +12 -12
  176. package/dist/login-pty-transport.test.js.map +1 -0
  177. package/dist/login-runner-lifecycle.d.ts +70 -0
  178. package/dist/login-runner-lifecycle.d.ts.map +1 -0
  179. package/dist/login-runner-lifecycle.js +52 -0
  180. package/dist/login-runner-lifecycle.js.map +1 -0
  181. package/dist/login-runner-lifecycle.test.d.ts +2 -0
  182. package/dist/login-runner-lifecycle.test.d.ts.map +1 -0
  183. package/dist/login-runner-lifecycle.test.js +122 -0
  184. package/dist/login-runner-lifecycle.test.js.map +1 -0
  185. package/dist/published-exports.test.d.ts +2 -0
  186. package/dist/published-exports.test.d.ts.map +1 -0
  187. package/dist/published-exports.test.js +17 -0
  188. package/dist/published-exports.test.js.map +1 -0
  189. package/dist/runtime-progress.d.ts.map +1 -1
  190. package/dist/runtime-progress.js +5 -1
  191. package/dist/runtime-progress.js.map +1 -1
  192. package/dist/runtime-progress.test.js +6 -6
  193. package/dist/runtime-progress.test.js.map +1 -1
  194. package/dist/sandbox-callback-bridge.d.ts +22 -0
  195. package/dist/sandbox-callback-bridge.d.ts.map +1 -1
  196. package/dist/sandbox-callback-bridge.js +944 -112
  197. package/dist/sandbox-callback-bridge.js.map +1 -1
  198. package/dist/sandbox-callback-bridge.test.js +285 -25
  199. package/dist/sandbox-callback-bridge.test.js.map +1 -1
  200. package/dist/sandbox-install-command.d.ts.map +1 -1
  201. package/dist/sandbox-install-command.js +5 -2
  202. package/dist/sandbox-install-command.js.map +1 -1
  203. package/dist/sandbox-install-command.test.js +4 -2
  204. package/dist/sandbox-install-command.test.js.map +1 -1
  205. package/dist/sandbox-managed-runtime.d.ts +17 -1
  206. package/dist/sandbox-managed-runtime.d.ts.map +1 -1
  207. package/dist/sandbox-managed-runtime.js +478 -330
  208. package/dist/sandbox-managed-runtime.js.map +1 -1
  209. package/dist/sandbox-managed-runtime.test.js +1125 -18
  210. package/dist/sandbox-managed-runtime.test.js.map +1 -1
  211. package/dist/server-utils.d.ts +1 -0
  212. package/dist/server-utils.d.ts.map +1 -1
  213. package/dist/server-utils.js +3 -3
  214. package/dist/server-utils.js.map +1 -1
  215. package/dist/server-utils.test.js +6 -1
  216. package/dist/server-utils.test.js.map +1 -1
  217. package/dist/session-compaction.d.ts.map +1 -1
  218. package/dist/session-compaction.js +6 -0
  219. package/dist/session-compaction.js.map +1 -1
  220. package/dist/ssh.d.ts.map +1 -1
  221. package/dist/ssh.js +34 -2
  222. package/dist/ssh.js.map +1 -1
  223. package/dist/sync-operation-schedule.d.ts +17 -0
  224. package/dist/sync-operation-schedule.d.ts.map +1 -0
  225. package/dist/sync-operation-schedule.js +66 -0
  226. package/dist/sync-operation-schedule.js.map +1 -0
  227. package/dist/sync-operation-schedule.test.d.ts +2 -0
  228. package/dist/sync-operation-schedule.test.d.ts.map +1 -0
  229. package/dist/sync-operation-schedule.test.js +130 -0
  230. package/dist/sync-operation-schedule.test.js.map +1 -0
  231. package/dist/types.d.ts +11 -2
  232. package/dist/types.d.ts.map +1 -1
  233. package/package.json +5 -2
  234. package/dist/setup-token-transport.d.ts.map +0 -1
  235. package/dist/setup-token-transport.js.map +0 -1
  236. package/dist/setup-token-transport.test.d.ts +0 -2
  237. package/dist/setup-token-transport.test.d.ts.map +0 -1
  238. 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 { createRuntimeSpanRunner, emitSkippedStartupStep, getActiveStepContext, measureStartupStep, NOOP_STARTUP_SPAN, NOOP_STARTUP_TRACE_CONTEXT, runWithRuntimeParent, setSandboxRootSpanAttributes, } from "./startup-timing.js";
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
- const fingerprint = shortHash({
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
- const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`;
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
- const staged = await withSessionStagingLease(stagingLocks, sessionKey, async () => {
1238
- const cachedStaged = isCompatibleResume ? stagedRuntimes.get(sessionKey) : undefined;
1239
- if (cachedStaged) {
1240
- // Reuse the already-staged in-sandbox workspace + managed home. Re-apply
1241
- // the env keys the seam repointed onto the in-sandbox home (deterministic,
1242
- // identical across the session's runs) and reuse the seam's per-run
1243
- // copy-back so the codex auth copy-back still fires on THIS run's teardown
1244
- // — the copy-back cadence stays exactly per-run, unchanged from PR 2. The
1245
- // copy-back reads the sandbox auth.json live at teardown, so the reused
1246
- // closure copies back the current credential, never a stale snapshot, and
1247
- // it never removes the staged in-sandbox home (host staged-temp cleanup
1248
- // moved to `dispose`, fired only when the entry is dropped), so reusing it
1249
- // can't leave this run without its staged home.
1250
- // (The workspace restore in that same closure diffs against the ORIGINAL
1251
- // staging run's host baseline — an accepted consequence of "reuse, don't
1252
- // re-ship": the in-sandbox workspace is the source of truth mid-session
1253
- // and the host stays synced from it each run.)
1254
- Object.assign(env, cachedStaged.envDelta);
1255
- cachedStaged.lastUsedAt = nowMs();
1256
- await input.ctx.onLog("stdout", "[paperclip] Reusing the staged in-sandbox runtime for this resumed session (no workspace re-ship / managed-home re-seed).\n");
1257
- return {
1258
- stagedRuntime: cachedStaged.stagedRuntime,
1259
- teardown: cachedStaged.teardown,
1260
- dispose: cachedStaged.dispose,
1261
- envDelta: cachedStaged.envDelta,
1262
- };
1263
- }
1264
- // Not a compatible resume (or no cache entry): stage fresh. If a stale
1265
- // entry sits at this key (e.g. an incompatible new session colliding on
1266
- // company/agent/task/fingerprint), drop it and release its host staged
1267
- // resources first so we neither reuse nor leak it.
1268
- const stale = stagedRuntimes.get(sessionKey);
1269
- if (stale) {
1270
- stagedRuntimes.delete(sessionKey);
1271
- if (stale.dispose)
1272
- await stale.dispose().catch(() => { });
1273
- }
1274
- // Record each successful `stage()` result before the seam can fail. A seam
1275
- // that throws AFTER a successful stage never returns its `disposeStaged`, so
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
- return { stagedRuntime: await stage([]), teardown: null, dispose: null };
1346
- }, stepMetrics);
1347
- const delta = {};
1348
- for (const [key, value] of Object.entries(env)) {
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
- teardown: freshTeardown,
1355
- dispose: freshDispose,
1356
- envDelta: delta,
1357
- };
1358
- });
1359
- sessionStagingLeaseRelease = staged.release;
1360
- stagedRuntime = staged.value.stagedRuntime;
1361
- remoteManagedHomeTeardown = staged.value.teardown;
1362
- remoteStagingDispose = staged.value.dispose;
1363
- remoteStagingEnvDelta = staged.value.envDelta;
1364
- // Publish the referenced-project workspace hints to the in-sandbox agent. The staged-directory
1365
- // map (`project-<projectId>`) is known only after staging above, so this runs here rather than
1366
- // with the initial workspace shaping. Each referenced hint repoints at its staged directory; a
1367
- // referenced hint whose project did not stage loses its cwd, so the agent never receives an
1368
- // unstaged path. Only the confined sandbox lane stages referenced trees, so only it publishes
1369
- // the hints; the local and runner-less lanes keep their env untouched. The set `env` write wins
1370
- // over an inherited value in the merged launch env.
1371
- const stagedProjectDirs = stagedRuntime?.additionalSourceDirs ?? {};
1372
- if (Object.keys(stagedProjectDirs).length > 0) {
1373
- const shapedHints = shapePaperclipWorkspaceEnvForExecution({
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: { ...executionTarget, streamRunLogs: false },
1412
- runtimeRootDir: stagedRuntime?.runtimeRootDir ?? null,
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
- }), concurrentBridgeStepMetrics);
1420
- // The single sequencing point (paperclip `env` → process-session launch).
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: executionTarget,
1435
- runtimeRootDir: stagedRuntime?.runtimeRootDir ?? null,
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
- // Deferred: the process-session bridge runs its env-independent setup,
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
- }), concurrentBridgeStepMetrics);
1449
- // Settle BOTH starts (mirrors `cleanupRemoteBridges`' `Promise.allSettled`):
1450
- // collect whichever handles started plus the first failure. Both handles
1451
- // stay individually declared so the catch below can stop whichever started.
1452
- const started = await settleRemoteBridgeStarts(paperclipStart, processSessionStart);
1453
- paperclipBridge = started.paperclipBridge;
1454
- processSessionBridge = started.processSessionBridge;
1455
- if (started.failure)
1456
- throw started.failure;
1457
- // Guarantee the paperclip env merge ran even if the process-session bridge
1458
- // returned without consuming the launch env (memoized ⇒ a no-op if it did).
1459
- await finalizeLaunchEnv();
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, but the returned prepared
1463
- // runtime and the log builder still read `runtimeEnv`.
1464
- runtimeEnv = resolveRuntimeEnv(env);
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 `cleanupRemoteBridges`). Both handles are individually
1471
- // declared above, so either may be non-null here.
1472
- await Promise.allSettled([paperclipBridge?.stop(), processSessionBridge?.stop()]);
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
- // (cleanupRemoteBridges, which normally releases it, is never reached here).
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
- * Bring up the two host-side sandbox bridges concurrently and settle both.
1606
- *
1607
- * Mirrors `cleanupRemoteBridges`' `Promise.allSettled` idiom (settle, not
1608
- * `Promise.all`): running BOTH starts to completion is what lets the caller STOP
1609
- * a bridge that DID start when its sibling threw — so a partial failure never
1610
- * leaks a live bridge. Returns whichever handles started plus the first failure
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
- async function cleanupRemoteBridges(prepared) {
1630
- try {
1631
- await Promise.allSettled([
1632
- prepared.processSessionBridge?.stop(),
1633
- prepared.paperclipBridge?.stop(),
1634
- ]);
1635
- // Runs AFTER the bridges stop (mirrors the CLI finally: stop bridge → restore
1636
- // workspace). Fires the codex auth copy-back via `restoreWorkspace()` and
1637
- // removes staged temp dirs. The seam logs and swallows its own failures — an
1638
- // unclean-teardown copy-back miss is the accepted, loud `refresh_token_reused`
1639
- // residual on the next host Codex use, never silent HOST-credential corruption
1640
- // — so a teardown fault never masks or fails the run result here.
1641
- if (prepared.remoteManagedHomeTeardown) {
1642
- await prepared.remoteManagedHomeTeardown().catch(() => { });
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 `cleanupRemoteBridges`; eviction fires
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
- const settleRunResources = async () => {
2565
- if (runResourcesSettled)
2566
- return;
2567
- runResourcesSettled = true;
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 cleanupRemoteBridges(prepared);
2550
+ await run();
2570
2551
  }
2571
- catch (teardownErr) {
2572
- await recordTeardownError("cleanup-remote-bridges", teardownErr);
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
- try {
2583
- // Publish the `sandbox.startup` context to the runtime-parent store for
2584
- // the whole bring-up. A startup-body exec that runs outside a measured
2585
- // step reads this token and parents its span to `sandbox.startup`, not to
2586
- // a detached root. A measured step nests its own `activeStepContextStorage`
2587
- // run inside this wrap and overrides the store, so an in-step exec still
2588
- // parents to its step span. On a local or SSH target
2589
- // `spanParent.parentContext` is a no-op token, so the wrap is inert.
2590
- prepared = await runWithRuntimeParent(spanParent.parentContext, () => buildRuntime({ ctx, engine, deps, spanParent, getRuntimeParentContext, runtimeSpan: runRuntimeSpan, stageRuntimeSpan: runStageSpan }));
2591
- buildRuntimeSettled = true;
2592
- // Per-project staging outcomes for the referenced (mentioned) projects, surfaced back to the
2593
- // server on the run result. A referenced project that failed to stage into the sandbox is a
2594
- // first-class, counted failure in the requested-vs-synced observability, not only a warning. The
2595
- // list is empty on a local target, on a transport that does not stage referenced projects, or
2596
- // when every staged referenced project succeeded, so the spread adds the field only when there
2597
- // is a failure to report.
2598
- const referencedProjectStagingFailures = (prepared.stagedRuntime?.additionalSourceFailures ?? []).map((failure) => ({ projectId: failure.projectId }));
2599
- referencedProjectStagingFailuresField =
2600
- referencedProjectStagingFailures.length > 0 ? { referencedProjectStagingFailures } : {};
2601
- // State the effective wall-clock timeout and its source up front so a
2602
- // later timeout is diagnosable from the run log alone. Goes to stderr:
2603
- // the acpx stdout log stream carries JSON acpx.* event payloads and must
2604
- // stay machine-parseable line by line.
2605
- await ctx.onLog("stderr", `[paperclip] ${formatAdapterExecutionTimeoutStartLogLine(prepared.timeoutResolution)}\n`);
2606
- await cleanupIdleHandles({ handles: warmHandles, now: now(), idleMs: warmIdleMs });
2607
- const previousParams = parseObject(ctx.runtime.sessionParams);
2608
- const canResume = isCompatibleSession(previousParams, prepared);
2609
- const resumeSessionId = canResume ? asString(previousParams.acpSessionId, "") || undefined : undefined;
2610
- const cached = canResume ? warmHandles.get(prepared.sessionKey) : undefined;
2611
- childStderrState = cached?.childStderrState ?? { logPath: null, pendingLiveLine: "" };
2612
- processIdentitySink = cached?.processIdentitySink ?? {
2613
- current: ctx.onSpawn,
2614
- latest: null,
2615
- };
2616
- // ACPX runtimes can stay warm across heartbeat runs. Keep the callback
2617
- // target mutable so a later agent respawn records identity on the current
2618
- // heartbeat instead of the run that originally created the runtime.
2619
- processIdentitySink.current = ctx.onSpawn;
2620
- flushChildStderr(childStderrState);
2621
- childStderrState.logPath = prepared.childStderrLogPath;
2622
- const runtimeOptions = {
2623
- cwd: prepared.cwd,
2624
- // Host-only spawn cwd for the relay proxy on the remote process-session
2625
- // lane; `undefined` elsewhere so acpx falls back to `cwd` (byte-identical).
2626
- // The advertised `session/new` cwd (`prepared.cwd` = `remoteCwd`) and the
2627
- // fingerprint / compat key are unaffected — this redirects ONLY the host
2628
- // `spawn()` `chdir`, not the in-sandbox data path.
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
- if (!handle) {
2682
- try {
2683
- // Step 7 — acp.handshake: ACP session establishment (session/new or
2684
- // resume). A throwing handshake still reports its duration before the
2685
- // resume-retry path below runs. The createRuntime/ensureSession
2686
- // sub-split rides the step span as fixed, closed keys (Open Q2).
2687
- let ensureSessionMs;
2688
- handle = await measureStartupStep(ctx, now, "acp.handshake", async () => {
2689
- const ensureSessionStart = now();
2690
- const established = await runtime.ensureSession({
2691
- sessionKey: prepared.sessionKey,
2692
- agent: prepared.acpxAgent,
2693
- mode: prepared.mode,
2694
- cwd: prepared.cwd,
2695
- resumeSessionId,
2696
- sessionOptions: { env: prepared.env },
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
- ensureSessionMs = now() - ensureSessionStart;
2699
- return established;
2700
- }, {
2701
- ...prepared.stepMetrics,
2702
- // The two sub-times ride the span as fixed, closed keys.
2703
- spanWallTimes: () => ({
2704
- createRuntime: createRuntimeMs,
2705
- ensureSession: ensureSessionMs,
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
- catch (err) {
2710
- if (!resumeSessionId || !isResumeFailure(err))
2711
- throw err;
2712
- clearSession = true;
2713
- resumedSession = false;
2714
- await ctx.onLog("stdout", `[paperclip] ACPX resume session "${resumeSessionId}" is unavailable; retrying with a fresh session.\n`);
2715
- // Fresh-session retry: the runtime was already constructed on the
2716
- // first attempt (never re-created), so this event reports only its
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
- else {
2739
- // Warm-handle hit: a compatible cached handle reuses the running ACP
2740
- // agent, so the `acp.handshake` step does no work. Emit a step span and
2741
- // event with `outcome = skipped` and a zero wall time, so the trace and
2742
- // the run log show the skip as a distinct outcome, never a misleading
2743
- // zero-work `ok` step.
2744
- await emitSkippedStartupStep(ctx, "acp.handshake", {
2745
- tracer: prepared.stepMetrics.tracer,
2746
- parentContext: prepared.stepMetrics.parentContext,
2747
- });
2748
- }
2749
- // A compatible warm handle reuses the already-running ACP agent and does
2750
- // not emit another spawn event. Persist its known identity on this run
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
- }).catch((closeErr) => recordTeardownError("runtime-close", closeErr));
2774
- const existing = warmHandles.get(prepared.sessionKey);
2775
- if (warmHandleMatches(existing, runtime, handle) && existing) {
2776
- clearWarmHandleTimer(existing);
2777
- warmHandles.delete(prepared.sessionKey);
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
- return {
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
- finally {
2803
- await settleRunResources();
2804
- }
2805
- }
2806
- if (!handle) {
2807
- // ensureSession returned no session handle. Close the runtime the run
2808
- // constructed so its child process cannot leak. There is no established
2809
- // handle, so build a minimal one from the prepared identity; the close is
2810
- // best effort and never blocks the error result.
2811
- await runtime.close({
2812
- handle: {
2813
- sessionKey: prepared.sessionKey,
2814
- backend: prepared.acpxAgent,
2815
- runtimeSessionName: prepared.sessionKey,
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
- finally {
2836
- await settleRunResources();
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
- // The post-build runtime-creation window failed after buildRuntime returned
2850
- // live bridges and a held staging lease. Drop the staged runtime, then emit
2851
- // and settle: `settleRunResources` in the finally stops the bridges,
2852
- // releases the staging lease, and flushes the child stderr even if the
2853
- // emission throws.
2854
- await discardStagedRuntime({ handles: stagedRuntimes, prepared });
2855
- try {
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
- return {
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
- await settleRunResources();
2878
- }
2879
- }
2880
- finally {
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
- await discardStagedRuntime({ handles: stagedRuntimes, prepared });
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
- return {
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
- finally {
2938
- await settleRunResources();
2939
- }
2940
- }
2941
- let cancelActiveTurn = null;
2942
- let controller = null;
2943
- let timeout = null;
2944
- let timedOut = false;
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
- const toolTitles = new Map();
3035
- for await (const event of turn.events) {
3036
- if (event.type === "text_delta")
3037
- textParts.push(event.text);
3038
- if (event.type === "status" && event.tag === "usage_update") {
3039
- eventBreakdown = event.breakdown ?? eventBreakdown;
3040
- eventCostUsd = usdCostAmount(event.cost) ?? eventCostUsd;
3041
- }
3042
- await emitRuntimeEvent(ctx, event, toolTitles);
3043
- }
3044
- const terminal = await turn.result;
3045
- if (timeout)
3046
- clearTimeout(timeout);
3047
- // Read usage before the close/warm-handle paths below can discard state.
3048
- const postTurnStatus = await readRuntimeStatus(runtime, sessionHandle);
3049
- const turnUsage = summarizeAcpxTurnUsage({
3050
- preStatus: preTurnStatus,
3051
- postStatus: postTurnStatus,
3052
- eventBreakdown,
3053
- eventCostUsd,
3054
- });
3055
- if (terminal.status === "failed" || terminal.status === "cancelled" || timedOut) {
3056
- const existing = warmHandles.get(prepared.sessionKey);
3057
- if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
3058
- await closeWarmHandle({
3059
- handles: warmHandles,
3060
- key: prepared.sessionKey,
3061
- entry: existing,
3062
- reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
3063
- discardPersistentState: terminal.status === "cancelled" || timedOut,
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
- else {
3067
- await runtime.close({
3068
- handle: sessionHandle,
3069
- reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
3070
- discardPersistentState: terminal.status === "cancelled" || timedOut,
3071
- }).catch(() => { });
3072
- }
3073
- }
3074
- else if (prepared.mode === "persistent" && warmIdleMs > 0 && !prepared.processSessionBridge) {
3075
- const existing = warmHandles.get(prepared.sessionKey);
3076
- if (existing && !warmHandleMatches(existing, runtime, sessionHandle)) {
3077
- await runtime.close({
3078
- handle: sessionHandle,
3079
- reason: "paperclip duplicate warm handle cleanup",
3080
- discardPersistentState: false,
3081
- }).catch(() => { });
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
- else {
3084
- const entry = {
3085
- runtime,
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
- childStderrState,
3088
- processIdentitySink,
3089
- fingerprint: prepared.fingerprint,
3090
- lastUsedAt: now(),
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
- warmHandles.set(prepared.sessionKey, entry);
3093
- scheduleIdleHandleCleanup({
3094
- handles: warmHandles,
3095
- key: prepared.sessionKey,
3096
- entry,
3097
- idleMs: warmIdleMs,
3098
- now,
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
- else {
3103
- const existing = warmHandles.get(prepared.sessionKey);
3104
- if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
3105
- await closeWarmHandle({
3106
- handles: warmHandles,
3107
- key: prepared.sessionKey,
3108
- entry: existing,
3109
- reason: "paperclip completed turn cleanup",
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 runtime.close({
3114
- handle: sessionHandle,
3115
- reason: "paperclip completed turn cleanup",
3116
- discardPersistentState: false,
3117
- }).catch(() => { });
3333
+ await emitPhase("turn", turnPhaseStart, "failed");
3118
3334
  }
3119
- }
3120
- // PR 3: keep the staged runtime warm for the next compatible resume only
3121
- // after a clean turn; a failed/cancelled/timed-out turn discards it so the
3122
- // next run stages fresh instead of reusing a torn-down session's staged
3123
- // credentials. Copy-back still fires for every outcome via
3124
- // `cleanupRemoteBridges` below (unchanged from PR 2).
3125
- if (terminal.status === "completed" && !timedOut) {
3126
- saveStagedRuntimeAfterCleanTurn({ handles: stagedRuntimes, prepared, now: now() });
3127
- }
3128
- else {
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
- }).catch((closeErr) => recordTeardownError("runtime-close", closeErr));
3213
- const existing = warmHandles.get(prepared.sessionKey);
3214
- if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
3215
- clearWarmHandleTimer(existing);
3216
- warmHandles.delete(prepared.sessionKey);
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
- await discardStagedRuntime({ handles: stagedRuntimes, prepared });
3219
- }
3220
- try {
3221
- const { classified, message } = await emitAcpxFailure({
3222
- ctx,
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
- await settleRunResources();
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
- finally {
3248
- // End the agent turn span exactly once, on every return and on a throw.
3249
- // `runFailed` is `false` only on a completed, non-timed-out turn, so the
3250
- // span status is correct for success, error, and timeout.
3251
- turnSpan.end(runFailed);
3252
- // Reset the current-run holder to the `task.run` token after the turn.
3253
- // The run stays live here, so the holder is never `undefined`. A detached
3254
- // exec after the turn parents to `task.run`.
3255
- currentRunParentContext = runRootSpan.parentContext;
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
  }