@humwick/runner 0.0.0-stage → 0.130.1

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 (293) hide show
  1. package/LICENSE +167 -0
  2. package/agent-bin/agent-relay-browser +10 -0
  3. package/agent-bin/humwick +10 -0
  4. package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
  5. package/hooks/fff-search-redirect/hooks.json +13 -0
  6. package/hooks/rg-r-guard/hooks.json +18 -0
  7. package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
  8. package/hooks/web-tools-redirect/hooks.json +13 -0
  9. package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
  10. package/node_modules/@humwick/offload/LICENSE +167 -0
  11. package/node_modules/@humwick/offload/README.md +57 -0
  12. package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
  13. package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
  14. package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
  15. package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
  16. package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
  17. package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
  18. package/node_modules/@humwick/offload/dist/envelope.js +80 -0
  19. package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
  20. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
  21. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
  22. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
  23. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
  24. package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
  25. package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
  26. package/node_modules/@humwick/offload/dist/index.js +5 -0
  27. package/node_modules/@humwick/offload/dist/index.js.map +1 -0
  28. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
  29. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
  30. package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
  31. package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
  32. package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
  33. package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
  34. package/node_modules/@humwick/offload/dist/types.js +176 -0
  35. package/node_modules/@humwick/offload/dist/types.js.map +1 -0
  36. package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
  37. package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
  38. package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
  39. package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
  40. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
  41. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
  42. package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
  43. package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
  44. package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
  45. package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
  46. package/node_modules/@humwick/offload/dist/worker.js +1 -0
  47. package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
  48. package/node_modules/@humwick/offload/package.json +42 -0
  49. package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
  50. package/node_modules/@humwick/offload/src/envelope.ts +92 -0
  51. package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
  52. package/node_modules/@humwick/offload/src/index.ts +4 -0
  53. package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
  54. package/node_modules/@humwick/offload/src/types.ts +235 -0
  55. package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
  56. package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
  57. package/node_modules/@humwick/offload/src/worker.ts +5 -0
  58. package/package.json +47 -4
  59. package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
  60. package/plugins/claude/.claude-plugin/plugin.json +8 -0
  61. package/plugins/claude/claude-statusline-probe.cjs +144 -0
  62. package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
  63. package/plugins/claude/hooks/gate-run-guard.sh +9 -0
  64. package/plugins/claude/hooks/gate-run-guard.ts +237 -0
  65. package/plugins/claude/hooks/hooks.json +158 -0
  66. package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
  67. package/plugins/claude/hooks/kill-guard.sh +341 -0
  68. package/plugins/claude/hooks/kill-guard.ts +337 -0
  69. package/plugins/claude/hooks/permission-request.sh +22 -0
  70. package/plugins/claude/hooks/post-compact.sh +10 -0
  71. package/plugins/claude/hooks/post-tool-use.sh +8 -0
  72. package/plugins/claude/hooks/pre-compact.sh +12 -0
  73. package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
  74. package/plugins/claude/hooks/relay-status.sh +373 -0
  75. package/plugins/claude/hooks/session-end.sh +26 -0
  76. package/plugins/claude/hooks/session-start.sh +31 -0
  77. package/plugins/claude/hooks/shell-trust.ts +28 -0
  78. package/plugins/claude/hooks/stop-failure.sh +47 -0
  79. package/plugins/claude/hooks/stop.sh +48 -0
  80. package/plugins/claude/hooks/subagent-guard.cjs +26 -0
  81. package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
  82. package/plugins/claude/hooks/subagent-start.sh +13 -0
  83. package/plugins/claude/hooks/subagent-stop.sh +19 -0
  84. package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
  85. package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
  86. package/plugins/claude/monitors/monitors.json +7 -0
  87. package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
  88. package/plugins/claude/monitors/relay-monitor.ts +58 -0
  89. package/plugins/claude/skills/disconnect/SKILL.md +16 -0
  90. package/plugins/claude/skills/label/SKILL.md +23 -0
  91. package/plugins/claude/skills/message/SKILL.md +40 -0
  92. package/plugins/claude/skills/pair/SKILL.md +30 -0
  93. package/plugins/claude/skills/react/SKILL.md +18 -0
  94. package/plugins/claude/skills/read-message/SKILL.md +24 -0
  95. package/plugins/claude/skills/reply/SKILL.md +44 -0
  96. package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
  97. package/plugins/claude/skills/slice-first/SKILL.md +32 -0
  98. package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
  99. package/plugins/claude/skills/status/SKILL.md +16 -0
  100. package/plugins/claude/skills/tags/SKILL.md +25 -0
  101. package/plugins/claude/skills/task/SKILL.md +28 -0
  102. package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
  103. package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
  104. package/plugins/codex/skills/disconnect/SKILL.md +15 -0
  105. package/plugins/codex/skills/guide/SKILL.md +15 -0
  106. package/plugins/codex/skills/label/SKILL.md +22 -0
  107. package/plugins/codex/skills/message/SKILL.md +40 -0
  108. package/plugins/codex/skills/pair/SKILL.md +29 -0
  109. package/plugins/codex/skills/react/SKILL.md +17 -0
  110. package/plugins/codex/skills/read-message/SKILL.md +23 -0
  111. package/plugins/codex/skills/reply/SKILL.md +44 -0
  112. package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
  113. package/plugins/codex/skills/slice-first/SKILL.md +32 -0
  114. package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
  115. package/plugins/codex/skills/status/SKILL.md +15 -0
  116. package/plugins/codex/skills/tags/SKILL.md +24 -0
  117. package/plugins/codex/skills/task/SKILL.md +28 -0
  118. package/src/adapter.ts +1061 -0
  119. package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
  120. package/src/adapters/claude-api.ts +243 -0
  121. package/src/adapters/claude-context-command.ts +16 -0
  122. package/src/adapters/claude-credentials.ts +22 -0
  123. package/src/adapters/claude-delivery-lanes.ts +28 -0
  124. package/src/adapters/claude-delivery.ts +166 -0
  125. package/src/adapters/claude-effort.ts +94 -0
  126. package/src/adapters/claude-exit.ts +290 -0
  127. package/src/adapters/claude-headless-mirror.ts +88 -0
  128. package/src/adapters/claude-headless-refusal.ts +51 -0
  129. package/src/adapters/claude-headless.ts +617 -0
  130. package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
  131. package/src/adapters/claude-permission.ts +243 -0
  132. package/src/adapters/claude-prompt-gates.ts +269 -0
  133. package/src/adapters/claude-prompt-hook.ts +145 -0
  134. package/src/adapters/claude-session-capture.ts +293 -0
  135. package/src/adapters/claude-session-probe.ts +414 -0
  136. package/src/adapters/claude-shutdown.ts +190 -0
  137. package/src/adapters/claude-status-detectors.ts +426 -0
  138. package/src/adapters/claude-subagents.ts +93 -0
  139. package/src/adapters/claude-submit-confirmation.ts +287 -0
  140. package/src/adapters/claude-tmux.ts +548 -0
  141. package/src/adapters/claude-token-usage-capture.ts +253 -0
  142. package/src/adapters/claude-tool-policy.ts +165 -0
  143. package/src/adapters/claude-transcript-tail.ts +193 -0
  144. package/src/adapters/claude-transcript.ts +812 -0
  145. package/src/adapters/claude.ts +1097 -0
  146. package/src/adapters/codex-agent-message-capture.ts +189 -0
  147. package/src/adapters/codex-app-server.ts +70 -0
  148. package/src/adapters/codex-client.ts +362 -0
  149. package/src/adapters/codex-effective-config.ts +87 -0
  150. package/src/adapters/codex-launch-config.ts +153 -0
  151. package/src/adapters/codex-rollout-context.ts +272 -0
  152. package/src/adapters/codex-skill-discovery.ts +18 -0
  153. package/src/adapters/codex-token-usage-capture.ts +163 -0
  154. package/src/adapters/codex-tool-output.ts +53 -0
  155. package/src/adapters/codex-turn-outcome.ts +27 -0
  156. package/src/adapters/codex-version.ts +64 -0
  157. package/src/adapters/codex.ts +1442 -0
  158. package/src/adapters/incremental-file.ts +110 -0
  159. package/src/adapters/opencode-api.ts +71 -0
  160. package/src/adapters/opencode-context.ts +277 -0
  161. package/src/adapters/opencode-model-registry.ts +126 -0
  162. package/src/adapters/opencode-provider.ts +169 -0
  163. package/src/adapters/opencode-server-retry.ts +50 -0
  164. package/src/adapters/opencode-soft-steer.ts +302 -0
  165. package/src/adapters/opencode-spawn.ts +145 -0
  166. package/src/adapters/opencode-subscribe.ts +62 -0
  167. package/src/adapters/opencode-token-usage-capture.ts +187 -0
  168. package/src/adapters/opencode-tool-events.ts +302 -0
  169. package/src/adapters/opencode-tools-config.ts +17 -0
  170. package/src/adapters/opencode.ts +2264 -0
  171. package/src/adapters/project-shell.ts +80 -0
  172. package/src/adapters/provider-launch-config.ts +21 -0
  173. package/src/adapters/provider-quota-harvest.ts +127 -0
  174. package/src/adapters/service-access.ts +153 -0
  175. package/src/adapters/tmux-offload.ts +207 -0
  176. package/src/adapters/tool-summary.ts +214 -0
  177. package/src/agent-redact.ts +75 -0
  178. package/src/agent-runner/commands/dispatch.ts +185 -0
  179. package/src/agent-runner/commands/lifecycle.ts +84 -0
  180. package/src/agent-runner/commands/prompt.ts +284 -0
  181. package/src/agent-runner/credentials.ts +208 -0
  182. package/src/agent-runner/http-liveness.ts +85 -0
  183. package/src/agent-runner/lifecycle-milestones.ts +207 -0
  184. package/src/agent-runner/lifecycle-phase.ts +80 -0
  185. package/src/agent-runner/registration.ts +163 -0
  186. package/src/agent-runner/runner-context.ts +158 -0
  187. package/src/agent-runner/session-mirror.ts +278 -0
  188. package/src/agent-runner/task-claims.ts +116 -0
  189. package/src/agent-runner/token-usage-coverage.ts +54 -0
  190. package/src/agent-runner/turn-report.ts +269 -0
  191. package/src/api-budget-accounting.ts +91 -0
  192. package/src/area-memory.ts +255 -0
  193. package/src/artifact-path-materialize.ts +52 -0
  194. package/src/async-guard.ts +35 -0
  195. package/src/attachment-cache-sweep.ts +31 -0
  196. package/src/attachment-cache.ts +195 -0
  197. package/src/background-script-reconcile.ts +106 -0
  198. package/src/bounded-log.ts +59 -0
  199. package/src/bounded-spawn-sync.ts +18 -0
  200. package/src/busy-reconciler.ts +282 -0
  201. package/src/claim-tracker.ts +140 -0
  202. package/src/claude-settings.ts +167 -0
  203. package/src/claude-workspace-trust.ts +289 -0
  204. package/src/config.ts +547 -0
  205. package/src/context-boundary.ts +101 -0
  206. package/src/context-command.ts +151 -0
  207. package/src/context-probe-state.ts +6 -0
  208. package/src/continuation-archive.ts +179 -0
  209. package/src/control-server.ts +730 -0
  210. package/src/delivery-policy.ts +9 -0
  211. package/src/delivery-result.ts +24 -0
  212. package/src/effort-command.ts +125 -0
  213. package/src/empty-turn.ts +816 -0
  214. package/src/free-port.ts +25 -0
  215. package/src/fs-offload.ts +26 -0
  216. package/src/gate-run-config.ts +35 -0
  217. package/src/gate-run-lease-probe.ts +38 -0
  218. package/src/host-asset-plan.ts +450 -0
  219. package/src/host-claude-config-worker.ts +34 -0
  220. package/src/host-sources.ts +40 -0
  221. package/src/inbox-carrier.ts +7 -0
  222. package/src/index.ts +386 -0
  223. package/src/launch-assembly.ts +955 -0
  224. package/src/liveness.ts +105 -0
  225. package/src/logger.ts +108 -0
  226. package/src/mcp-outbox.ts +204 -0
  227. package/src/mcp-preflight.ts +86 -0
  228. package/src/mcp-source-selection.ts +54 -0
  229. package/src/mcp-tool-deferral.ts +35 -0
  230. package/src/mcp-vault-env.ts +25 -0
  231. package/src/message-body-config/config.ts +16 -0
  232. package/src/message-body-config.ts +1 -0
  233. package/src/mid-turn-delivery.ts +43 -0
  234. package/src/native-memory-lever.ts +329 -0
  235. package/src/native-self-resume.ts +244 -0
  236. package/src/outbox.ts +453 -0
  237. package/src/park-post.ts +17 -0
  238. package/src/pending-permission-store.ts +148 -0
  239. package/src/process-meta.ts +9 -0
  240. package/src/profile-home.ts +419 -0
  241. package/src/profile-projection.ts +411 -0
  242. package/src/project-hooks.ts +65 -0
  243. package/src/project-instructions.ts +119 -0
  244. package/src/provider-double.ts +59 -0
  245. package/src/provider-hold.ts +228 -0
  246. package/src/provider-home-marker.ts +3 -0
  247. package/src/provider-home-tombstone.ts +4 -0
  248. package/src/provider-input-queue.ts +134 -0
  249. package/src/provider-integration.ts +93 -0
  250. package/src/provider-processes.ts +17 -0
  251. package/src/provider-submit-failure.ts +15 -0
  252. package/src/providers.ts +35 -0
  253. package/src/provisioning.ts +703 -0
  254. package/src/question-decision.ts +18 -0
  255. package/src/quota.ts +22 -0
  256. package/src/rate-limit.ts +351 -0
  257. package/src/refused-prompt-store.ts +36 -0
  258. package/src/relay-injection-events.ts +161 -0
  259. package/src/relay-instructions.ts +107 -0
  260. package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
  261. package/src/relay-mcp-proxy.ts +704 -0
  262. package/src/relay-mcp.ts +583 -0
  263. package/src/reply-obligation-cache.ts +211 -0
  264. package/src/response-capture-report.ts +111 -0
  265. package/src/response-routing.ts +6 -0
  266. package/src/runner-core.ts +2955 -0
  267. package/src/runner-exit.ts +12 -0
  268. package/src/runner-helpers.ts +582 -0
  269. package/src/runner-insights.ts +105 -0
  270. package/src/runner-mcp-proxy.ts +46 -0
  271. package/src/runner-spawn-config.ts +68 -0
  272. package/src/runner-stale-scratch-sweep.ts +21 -0
  273. package/src/runner.ts +9 -0
  274. package/src/runtime-token-file.ts +45 -0
  275. package/src/runtime-token-remint.ts +56 -0
  276. package/src/service-access.ts +88 -0
  277. package/src/session-destroy.ts +27 -0
  278. package/src/session-insights.ts +118 -0
  279. package/src/session-scratch.ts +296 -0
  280. package/src/spawn-input.ts +147 -0
  281. package/src/startup-config-fetch.ts +48 -0
  282. package/src/subreaper.ts +113 -0
  283. package/src/template-resolver.ts +29 -0
  284. package/src/test-run-dedup.ts +435 -0
  285. package/src/transcript-context.ts +99 -0
  286. package/src/transport-errors.ts +48 -0
  287. package/src/turn-opener.ts +55 -0
  288. package/src/turn-reconcile-event.ts +88 -0
  289. package/src/vault-proxy-hop.ts +99 -0
  290. package/src/version.ts +44 -0
  291. package/src/web-tool-call-proxy.ts +128 -0
  292. package/src/web-tools.ts +59 -0
  293. package/README.md +0 -3
@@ -0,0 +1,435 @@
1
+ import { relayEnv } from "@humwick/sdk/env";
2
+ // #2142 — per-agent test-run dedup ("latest wins").
3
+ //
4
+ // Workers run `bun run test:affected` after every file change. When a change touches something
5
+ // broad, a single run takes 10-15 minutes — and the agent starts the NEXT run long before the
6
+ // previous one finishes. The stacked runs saturate the host: each one holds a gate slot, forks
7
+ // its own shard processes, and the oldest run's result is thrown away the moment it lands.
8
+ // Instructions don't fix this (an agent rationalizes around them), so the bound has to be
9
+ // structural and live BELOW the agent: the runner watches its own session's process tree and,
10
+ // whenever it sees more than one independent test run, kills every run except the newest.
11
+ //
12
+ // Scoping is per-agent BY CONSTRUCTION: the sweep only ever looks at processes it can PROVE belong
13
+ // to this agent — descendants of this session's provider process (the tmux pane pid / direct child
14
+ // pid the adapter already resolves for busy-liveness), plus processes carrying this agent's
15
+ // AGENT_RELAY_ID in their environment. A sibling agent's runs satisfy neither test, so this can
16
+ // never fratricide the fleet the way a host-wide `pkill -f` would (#753). It is also
17
+ // provider-agnostic — nothing here knows whether the tree belongs to Claude or Codex.
18
+ //
19
+ // #2461 — the env-tag half of that ownership test is new, and it is why this file exists at all
20
+ // after the lease landed. Ancestry ALONE made this a no-op for the case the issue reports: a
21
+ // `relay_run` gate run executes under the ORCHESTRATOR daemon, a tree that shares no ancestor with
22
+ // the provider pane, so a descendant-scoped sweep could never see it. The orchestrator now stamps
23
+ // AGENT_RELAY_ID onto those children, which is what makes them attributable here.
24
+ //
25
+ // This sweep is now the BACKSTOP, not the primary mechanism. Kill-replace happens at LAUNCH, in
26
+ // sdk/src/gate-run-lease.ts, entered from `scripts/gate-slot` and the orchestrator. What is left
27
+ // for the sweep is the residual shape neither front door sees: a gate run started straight from the
28
+ // provider shell in a repo that does not route through `gate.sh`. It is 15s-late by construction —
29
+ // that is the documented coverage gap, tracked for a launch-exact PATH shim in the follow-up issue.
30
+ // When a live lease holder exists it is AUTHORITATIVE: the sweep keeps it and kills the rest, so
31
+ // the two mechanisms can never disagree about which run survives.
32
+ import { execFile } from "node:child_process";
33
+ import { readFileSync, readdirSync } from "node:fs";
34
+ import { promisify } from "node:util";
35
+ import { errMessage } from "@humwick/sdk";
36
+ import { killPid, waitForPidsExit } from "@humwick/sdk/process-utils";
37
+ import { classifyGateRun, isTestRunCommand, type GateRunCommands } from "@humwick/sdk/gate-run-classify";
38
+ import { GATE_RUN_AGENT_ENV } from "@humwick/sdk/gate-run-lease";
39
+ import type { ManagedProcess } from "./adapter";
40
+
41
+ // Re-exported so the sweep and both lease front doors share ONE definition of "is this a test run".
42
+ // It moved to the sdk in #2461 because the orchestrator (a separate workspace that cannot import
43
+ // runner/) needs the same rule; keeping the name exported here keeps every existing caller intact.
44
+ export { isTestRunCommand };
45
+
46
+ const execFileAsync = promisify(execFile);
47
+
48
+ /** How often the runner re-reads the process table looking for stacked runs. A stale run is
49
+ * therefore killed within a sweep of the new one starting — the waste this bounds is measured
50
+ * in minutes, so paying one `ps -e` per agent per sweep to catch it is a good trade. */
51
+ export const TEST_RUN_DEDUP_SWEEP_MS = 15_000;
52
+
53
+ /** Hard bound on the `ps` read, mirroring the sdk's process-table sweep: a hung `ps` must never
54
+ * suspend the runner's maintenance timers. `execFile` SIGKILLs at the deadline and rejects,
55
+ * which the caller treats as "no table this sweep". */
56
+ const PROCESS_TABLE_TIMEOUT_MS = 10_000;
57
+
58
+ /** `ps -eo args=` on a host with many agents produces a large table; the node default (1MB)
59
+ * would truncate it into a parse error rather than a partial read. */
60
+ const PROCESS_TABLE_MAX_BUFFER = 8 * 1024 * 1024;
61
+
62
+ /** Grace given to a SIGTERM'd run's tree before the sweep escalates to SIGKILL. `bun test`
63
+ * handles SIGTERM promptly; a wedged shard must not be allowed to keep its gate slot. */
64
+ const KILL_GRACE_MS = 3_000;
65
+
66
+ export interface ProcessRow {
67
+ pid: number;
68
+ ppid: number;
69
+ /** Seconds since the process started (`ps etime`). LOWER means NEWER. */
70
+ elapsedSeconds: number;
71
+ /** Full argv joined by spaces (`ps args`). */
72
+ args: string;
73
+ }
74
+
75
+ function validPid(pid: unknown): pid is number {
76
+ return typeof pid === "number" && Number.isInteger(pid) && pid > 1;
77
+ }
78
+
79
+ /**
80
+ * Pick the process tree which actually executes this session's shell commands.
81
+ *
82
+ * Most providers expose that child as both `process.pid` and `pid`. Interactive
83
+ * Codex is intentionally different: `pid` is the visible remote TUI, while
84
+ * `process` is the App Server that owns Codex turns and their command children.
85
+ * Rooting at the TUI made #2142 a no-op for exactly those Codex sessions. Prefer
86
+ * the runner-owned execution child, then retain the historical managed-pid and
87
+ * adapter-specific fallbacks for tmux-backed providers.
88
+ */
89
+ export function resolveTestRunRootPid(
90
+ managedProcess: ManagedProcess | undefined,
91
+ resolveBusyPid?: (process: ManagedProcess) => number | undefined,
92
+ ): number | undefined {
93
+ const executionPid = managedProcess?.process?.pid;
94
+ if (validPid(executionPid)) return executionPid;
95
+ if (validPid(managedProcess?.pid)) return managedProcess.pid;
96
+ const fallbackPid = managedProcess ? resolveBusyPid?.(managedProcess) : undefined;
97
+ return validPid(fallbackPid) ? fallbackPid : undefined;
98
+ }
99
+
100
+ /** Parse `ps` `etime` (`[[dd-]hh:]mm:ss`) into seconds. Returns null when unparseable so the
101
+ * caller can drop the row rather than invent an age and mis-rank which run is newest. */
102
+ export function parseElapsedSeconds(etime: string): number | null {
103
+ const trimmed = etime.trim();
104
+ const match = trimmed.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
105
+ if (!match) return null;
106
+ const days = Number(match[1] ?? 0);
107
+ const hours = Number(match[2] ?? 0);
108
+ const minutes = Number(match[3]);
109
+ const seconds = Number(match[4]);
110
+ if (![days, hours, minutes, seconds].every(Number.isFinite)) return null;
111
+ return days * 86_400 + hours * 3_600 + minutes * 60 + seconds;
112
+ }
113
+
114
+ /** Parse the output of `ps -eo pid=,ppid=,etime=,args=`. Unparseable lines are skipped: a
115
+ * partial table still lets the sweep act on the runs it CAN see, and a row it cannot age is
116
+ * exactly the row it must not rank. */
117
+ export function parseProcessTable(text: string): ProcessRow[] {
118
+ const rows: ProcessRow[] = [];
119
+ for (const line of text.split("\n")) {
120
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/);
121
+ if (!match) continue;
122
+ const pid = Number(match[1]);
123
+ const ppid = Number(match[2]);
124
+ const elapsedSeconds = parseElapsedSeconds(match[3] ?? "");
125
+ const args = match[4];
126
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid) || elapsedSeconds === null || args === undefined) continue;
127
+ rows.push({ pid, ppid, elapsedSeconds, args });
128
+ }
129
+ return rows;
130
+ }
131
+
132
+ /**
133
+ * Pids whose ENVIRONMENT names `agentId` as their owner — the half of ownership that ancestry
134
+ * cannot express (#2461). A `relay_run` gate run is a child of the orchestrator daemon, not of this
135
+ * agent's provider pane, so it is reachable only through the tag the orchestrator stamps on it.
136
+ *
137
+ * Linux reads `/proc/<pid>/environ` directly: no subprocess, and unreadable entries (another user's
138
+ * process, a pid that exited mid-scan) are simply skipped. macOS has no /proc, so it takes ONE bulk
139
+ * `ps -E` — the environment is appended to the command column for processes this user owns, which
140
+ * is exactly the set that could be ours. Both paths fail CLOSED: an unreadable table yields no
141
+ * tagged pids, which narrows the sweep's scope rather than widening it.
142
+ */
143
+ export async function collectAgentTaggedPids(agentId: string, platform: string = process.platform): Promise<number[]> {
144
+ if (!agentId) return [];
145
+ const needle = `${GATE_RUN_AGENT_ENV}=${agentId}`;
146
+ if (platform === "linux") {
147
+ const pids: number[] = [];
148
+ let entries: string[];
149
+ try { entries = readdirSync("/proc"); } catch { return []; }
150
+ for (const entry of entries) {
151
+ const pid = Number(entry);
152
+ if (!Number.isInteger(pid) || pid <= 1) continue;
153
+ try {
154
+ // NUL-separated KEY=VALUE pairs; the tag must match a WHOLE entry, so a different agent id
155
+ // that merely has ours as a prefix can never be mistaken for it.
156
+ const environ = readFileSync(`/proc/${pid}/environ`, "utf8");
157
+ if (environ.split("\0").includes(needle)) pids.push(pid);
158
+ } catch {}
159
+ }
160
+ return pids;
161
+ }
162
+ try {
163
+ const { stdout } = await execFileAsync("ps", ["-E", "-ww", "-o", "pid=,command="], {
164
+ encoding: "utf8",
165
+ timeout: PROCESS_TABLE_TIMEOUT_MS,
166
+ killSignal: "SIGKILL",
167
+ maxBuffer: PROCESS_TABLE_MAX_BUFFER,
168
+ });
169
+ const pids: number[] = [];
170
+ for (const line of stdout.split("\n")) {
171
+ const match = line.match(/^\s*(\d+)\s+(.*)$/);
172
+ if (!match) continue;
173
+ // `ps -E` renders the environment space-separated, so a whole-token test is the analogue of
174
+ // the NUL split above: `AGENT_RELAY_ID=x` must not match agent `xy`.
175
+ if (!match[2]!.split(/\s+/).includes(needle)) continue;
176
+ const pid = Number(match[1]);
177
+ if (Number.isInteger(pid) && pid > 1) pids.push(pid);
178
+ }
179
+ return pids;
180
+ } catch {
181
+ return [];
182
+ }
183
+ }
184
+
185
+ export interface TestRunDedupPlan {
186
+ /** The surviving (newest) run's root pid, when a stale run was found. */
187
+ keptPid?: number;
188
+ /** Root pids of the runs to kill — every run strictly older than the newest. */
189
+ staleRoots: number[];
190
+ /** Every pid to signal, the stale roots plus their descendants, deepest-first. */
191
+ killPids: number[];
192
+ }
193
+
194
+ const EMPTY_PLAN: TestRunDedupPlan = { staleRoots: [], killPids: [] };
195
+
196
+ /**
197
+ * Decide which test runs to kill inside `rootPid`'s process tree. Pure, so the whole "latest
198
+ * wins" decision is testable without a real process tree.
199
+ *
200
+ * Two properties do the safety work here:
201
+ * - only STRICT descendants of `rootPid` are ever considered, so the blast radius is one agent;
202
+ * - a matching process that has a matching ANCESTOR is not a run root, it is a step INSIDE one
203
+ * (`bun run test:affected` → `bun run scripts/test-affected.ts` → `bun test <shard>`), so a
204
+ * single healthy run presents exactly one root and is never touched.
205
+ */
206
+ export interface TestRunDedupScope {
207
+ /** Pids proven to belong to this agent by their ENVIRONMENT rather than by ancestry (#2461).
208
+ * These extend the scope; they are never used as tree roots, because a tagged process is
209
+ * usually INSIDE the run rather than above it. */
210
+ ownedPids?: readonly number[];
211
+ /** The live gate-run lease holder, when there is one. AUTHORITATIVE: the sweep keeps this run and
212
+ * kills the rest, so the launch-time lease and this backstop can never disagree about which run
213
+ * survives. Absent ⇒ fall back to newest-wins. */
214
+ keepPid?: number | undefined;
215
+ /** Classifier override. Defaults to the argv-shape heuristic; the sweep passes a classifier bound
216
+ * to the repo's CONFIGURED gate commands so a foreign runner (`pnpm tier:run …`) is matched too. */
217
+ isGateRun?: (args: string) => boolean;
218
+ }
219
+
220
+ export function planTestRunDedup(
221
+ rows: ProcessRow[],
222
+ rootPid: number,
223
+ protectedPids: number[] = [],
224
+ scope: TestRunDedupScope = {},
225
+ ): TestRunDedupPlan {
226
+ const rootValid = Number.isInteger(rootPid) && rootPid > 1;
227
+ const owned = (scope.ownedPids ?? []).filter((pid) => Number.isInteger(pid) && pid > 1);
228
+ const isGateRun = scope.isGateRun ?? isTestRunCommand;
229
+ if (!rootValid && owned.length === 0) return EMPTY_PLAN;
230
+ const byPid = new Map<number, ProcessRow>();
231
+ const childrenByParent = new Map<number, number[]>();
232
+ for (const row of rows) {
233
+ byPid.set(row.pid, row);
234
+ const siblings = childrenByParent.get(row.ppid) ?? [];
235
+ siblings.push(row.pid);
236
+ childrenByParent.set(row.ppid, siblings);
237
+ }
238
+ const ownedInTable = owned.filter((pid) => byPid.has(pid));
239
+ if (!byPid.has(rootPid) && ownedInTable.length === 0) return EMPTY_PLAN;
240
+
241
+ const subtree = (pid: number): number[] => {
242
+ const seen = new Set<number>();
243
+ const queue = [pid];
244
+ while (queue.length > 0) {
245
+ const current = queue.shift()!;
246
+ if (seen.has(current)) continue;
247
+ seen.add(current);
248
+ for (const child of childrenByParent.get(current) ?? []) queue.push(child);
249
+ }
250
+ return [...seen];
251
+ };
252
+
253
+ // Ownership is the UNION of the two proofs: descended from this session's provider process, or
254
+ // carrying this agent's id in its environment. Neither alone covers both launch paths (#2461).
255
+ const sessionPids = new Set<number>(byPid.has(rootPid) ? subtree(rootPid) : []);
256
+ for (const pid of ownedInTable) sessionPids.add(pid);
257
+ const candidates = new Set<number>();
258
+ for (const pid of sessionPids) {
259
+ if (pid === rootPid) continue;
260
+ const row = byPid.get(pid);
261
+ if (row && isGateRun(row.args)) candidates.add(pid);
262
+ }
263
+ if (candidates.size < 2) return EMPTY_PLAN;
264
+
265
+ // A candidate is a run ROOT only when nothing between it and the session root is also a
266
+ // candidate — i.e. it is the outermost test process of its own run.
267
+ const runRoots: number[] = [];
268
+ for (const pid of candidates) {
269
+ let ancestor = byPid.get(pid)?.ppid;
270
+ let nested = false;
271
+ const walked = new Set<number>([pid]);
272
+ while (ancestor !== undefined && ancestor > 1 && ancestor !== rootPid && !walked.has(ancestor)) {
273
+ walked.add(ancestor);
274
+ if (candidates.has(ancestor)) { nested = true; break; }
275
+ ancestor = byPid.get(ancestor)?.ppid;
276
+ }
277
+ if (!nested) runRoots.push(pid);
278
+ }
279
+ if (runRoots.length < 2) return EMPTY_PLAN;
280
+
281
+ // Lower elapsed = started later = the run the agent actually wants. `ps etime` only has
282
+ // second precision, though, and a Codex agent can launch two commands within that second.
283
+ // Pids are allocated in start order within the host pid namespace, so the higher pid is the
284
+ // newer root for this otherwise-unresolvable tie. Without this deterministic tie-breaker, two
285
+ // same-second delivery runs remain tied forever and #2142 fails to bound the exact burst it
286
+ // was added for. The scope is still this session's process tree only.
287
+ // A live lease holder wins outright — it is the run the launch-time mechanism already chose. Only
288
+ // when there is none (an ungated run in a repo without gate.sh) does newest-wins decide.
289
+ const leaseKeep = scope.keepPid !== undefined && runRoots.includes(scope.keepPid) ? scope.keepPid : undefined;
290
+ const keptPid = leaseKeep ?? runRoots.reduce((newest, pid) => {
291
+ const candidate = byPid.get(pid)!;
292
+ const current = byPid.get(newest)!;
293
+ return candidate.elapsedSeconds < current.elapsedSeconds
294
+ || (candidate.elapsedSeconds === current.elapsedSeconds && pid > newest)
295
+ ? pid
296
+ : newest;
297
+ });
298
+ const newestElapsed = byPid.get(keptPid)!.elapsedSeconds;
299
+ const staleRoots = runRoots
300
+ // With a lease holder the age comparison is meaningless — the holder may be the OLDER run, and
301
+ // it still wins. Without one, keep the original guard so a mis-ranked row is never killed.
302
+ .filter((pid) => pid !== keptPid && (leaseKeep !== undefined || byPid.get(pid)!.elapsedSeconds >= newestElapsed))
303
+ .sort((a, b) => a - b);
304
+ if (staleRoots.length === 0) return EMPTY_PLAN;
305
+
306
+ const protectedSet = new Set([rootPid, ...protectedPids]);
307
+ const depthByPid = new Map<number, number>();
308
+ const killPids: number[] = [];
309
+ for (const staleRoot of staleRoots) {
310
+ // Depth-stamped walk so the signal order is deepest-first: killing a shard before its
311
+ // supervisor stops the supervisor from noticing the gap and starting a replacement.
312
+ const queue: Array<{ pid: number; depth: number }> = [{ pid: staleRoot, depth: 0 }];
313
+ while (queue.length > 0) {
314
+ const { pid, depth } = queue.shift()!;
315
+ if (depthByPid.has(pid) || protectedSet.has(pid) || pid <= 1) continue;
316
+ depthByPid.set(pid, depth);
317
+ killPids.push(pid);
318
+ for (const child of childrenByParent.get(pid) ?? []) queue.push({ pid: child, depth: depth + 1 });
319
+ }
320
+ }
321
+ killPids.sort((a, b) => (depthByPid.get(b)! - depthByPid.get(a)!) || (b - a));
322
+
323
+ return { keptPid, staleRoots, killPids };
324
+ }
325
+
326
+ /** Read the host process table with pid/ppid/age/argv. Bounded and SIGKILL-on-deadline. */
327
+ export async function readProcessTable(timeoutMs: number = PROCESS_TABLE_TIMEOUT_MS): Promise<string> {
328
+ const { stdout } = await execFileAsync("ps", ["-eo", "pid=,ppid=,etime=,args="], {
329
+ encoding: "utf8",
330
+ timeout: timeoutMs,
331
+ killSignal: "SIGKILL",
332
+ maxBuffer: PROCESS_TABLE_MAX_BUFFER,
333
+ });
334
+ return stdout;
335
+ }
336
+
337
+ export interface TestRunDedupDeps {
338
+ /** Resolve this session's provider pid. undefined ⇒ ancestry proves nothing; the env tag may. */
339
+ rootPid: () => number | undefined;
340
+ /** This agent's id, for the env-tag ownership proof (#2461). Absent ⇒ ancestry-only, as before. */
341
+ agentId?: () => string | undefined;
342
+ /** The repo's configured gate commands, so a foreign gate runner is classified exactly rather
343
+ * than left to the argv-shape heuristic. */
344
+ gateCommands?: () => GateRunCommands;
345
+ /** The live lease holder pid, when one exists — authoritative over newest-wins. */
346
+ leaseHolderPid?: () => number | undefined;
347
+ collectOwnedPids?: (agentId: string) => Promise<number[]>;
348
+ readProcessTable?: () => Promise<string>;
349
+ kill?: (pid: number, signal: "SIGTERM" | "SIGKILL") => void;
350
+ waitForExit?: (pids: number[], timeoutMs: number) => Promise<boolean>;
351
+ log: (message: string) => void;
352
+ /** Pids the sweep must never signal (the runner's own pid, etc.). */
353
+ protectedPids?: () => number[];
354
+ killGraceMs?: number;
355
+ }
356
+
357
+ /** Run one dedup pass. Returns the pids it signalled (empty when there was nothing to dedup). */
358
+ export async function runTestRunDedupSweep(deps: TestRunDedupDeps): Promise<number[]> {
359
+ const resolvedRoot = deps.rootPid();
360
+ const rootPid = resolvedRoot !== undefined && Number.isInteger(resolvedRoot) && resolvedRoot > 1 ? resolvedRoot : 0;
361
+ const agentId = deps.agentId?.() ?? "";
362
+ // #2461 — either proof of ownership is enough, but at least one is required. With neither, the
363
+ // sweep cannot show any process is ours and must do nothing at all.
364
+ const ownedPids = agentId ? await (deps.collectOwnedPids ?? collectAgentTaggedPids)(agentId) : [];
365
+ if (rootPid === 0 && ownedPids.length === 0) return [];
366
+
367
+ let table: string;
368
+ try {
369
+ table = await (deps.readProcessTable ?? readProcessTable)();
370
+ } catch (error) {
371
+ // A `ps` that fails or times out just means no dedup this sweep — never a runner failure.
372
+ deps.log(`test-run dedup: process table read failed: ${errMessage(error)}`);
373
+ return [];
374
+ }
375
+
376
+ const gateCommands = deps.gateCommands?.() ?? {};
377
+ const plan = planTestRunDedup(parseProcessTable(table), rootPid, deps.protectedPids?.() ?? [], {
378
+ ownedPids,
379
+ keepPid: deps.leaseHolderPid?.(),
380
+ isGateRun: (args) => classifyGateRun(args, gateCommands) !== null,
381
+ });
382
+ if (plan.killPids.length === 0) return [];
383
+
384
+ const kill = deps.kill ?? killPid;
385
+ const waitForExit = deps.waitForExit ?? waitForPidsExit;
386
+ deps.log(
387
+ `test-run dedup (#2142): a newer test run started, killing ${plan.staleRoots.length} superseded run(s) ` +
388
+ `[roots ${plan.staleRoots.join(", ")}; ${plan.killPids.length} pid(s)]` +
389
+ `${plan.keptPid !== undefined ? `; keeping ${plan.keptPid}` : ""}`,
390
+ );
391
+ for (const pid of plan.killPids) kill(pid, "SIGTERM");
392
+ const exited = await waitForExit(plan.killPids, deps.killGraceMs ?? KILL_GRACE_MS);
393
+ if (!exited) {
394
+ for (const pid of plan.killPids) kill(pid, "SIGKILL");
395
+ }
396
+ return plan.killPids;
397
+ }
398
+
399
+ /** True unless the operator has explicitly turned dedup off for this session. */
400
+ export function testRunDedupEnabled(env: Record<string, string | undefined> = process.env): boolean {
401
+ const raw = (relayEnv(env, "AGENT_RELAY_TEST_DEDUP") ?? "").trim().toLowerCase();
402
+ return raw !== "0" && raw !== "false" && raw !== "off";
403
+ }
404
+
405
+ /**
406
+ * Runner-side maintenance timer: same shape as the attachment-cache sweep — self-arming,
407
+ * single-flight, and a no-op the moment the session has no resolvable provider pid.
408
+ */
409
+ export class TestRunDedupSweep {
410
+ private timer?: Timer;
411
+ private inFlight = false;
412
+
413
+ constructor(private readonly deps: TestRunDedupDeps, private readonly intervalMs: number = TEST_RUN_DEDUP_SWEEP_MS) {}
414
+
415
+ start(): void {
416
+ if (this.timer || !testRunDedupEnabled()) return;
417
+ const sweep = () => {
418
+ if (this.inFlight) return;
419
+ this.inFlight = true;
420
+ void runTestRunDedupSweep(this.deps)
421
+ .catch((error) => this.deps.log(`test-run dedup sweep failed: ${errMessage(error)}`))
422
+ .finally(() => { this.inFlight = false; });
423
+ };
424
+ // Do not leave a just-started runner unprotected for its first whole interval.
425
+ // The interval remains the steady-state mechanism for later overlapping commands.
426
+ sweep();
427
+ this.timer = setInterval(sweep, this.intervalMs);
428
+ this.timer.unref?.();
429
+ }
430
+
431
+ stop(): void {
432
+ if (this.timer) clearInterval(this.timer);
433
+ this.timer = undefined;
434
+ }
435
+ }
@@ -0,0 +1,99 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import type { ContextState } from "@humwick/sdk";
3
+ import type { ProviderContextUsage } from "./adapter";
4
+
5
+ // #1512: Claude reports context only through its out-of-band status-line probe,
6
+ // which freezes after an in-process compact/clear/native-resume (no new process,
7
+ // so the probe file stops advancing while the agent keeps taking turns). The
8
+ // transcript — handed to the runner every turn by the Stop hook — carries the
9
+ // same per-turn token usage, so the runner derives fresh context from it here as
10
+ // a probe-independent fallback that keeps context.lastUpdatedAt advancing across
11
+ // those in-process generation bumps. Kept out of runner-core to hold that giant's
12
+ // line budget (epic #291); runner-core just threads its per-session cache through.
13
+
14
+ export interface TranscriptContextCache {
15
+ path: string;
16
+ mtimeMs: number;
17
+ /** Byte size alongside mtime: a same-second rewrite (coarse mtime granularity) still changes length. */
18
+ size: number;
19
+ usage: ProviderContextUsage | undefined;
20
+ }
21
+
22
+ export interface TranscriptContextInput {
23
+ /** Latest transcript path the Stop hook reported this session. */
24
+ transcriptPath: string | undefined;
25
+ /** The transcript-backed adapter's per-turn usage parser (absent for others). */
26
+ derive: ((jsonl: string) => ProviderContextUsage | undefined) | undefined;
27
+ /** Context-window size — stable for the session, so reuse what the probe saw. */
28
+ tokensMax: number | undefined;
29
+ /** Prior parse, reused when the transcript's (mtime, size) is unchanged (large files). */
30
+ cache: TranscriptContextCache | undefined;
31
+ now: number;
32
+ }
33
+
34
+ // Returns the derived context (or undefined when it can't be computed) and the
35
+ // cache to carry forward. The context-window size is stable for a session, so we
36
+ // reuse the tokensMax the status-line probe reported at least once rather than
37
+ // re-deriving it from a model catalog the runner doesn't hold; the transcript
38
+ // supplies the per-turn tokensUsed. The parse is cached by transcript (mtime, size)
39
+ // so a large log isn't re-read every 30s heartbeat while nothing new has been
40
+ // written. Size is paired with mtime because an in-process compact/clear/resume can
41
+ // rewrite the transcript within the same coarse mtime tick (1s on some filesystems),
42
+ // which mtime alone would miss — leaving stale context; the length still changes.
43
+ export function transcriptDerivedContext(input: TranscriptContextInput): {
44
+ context: ContextState | undefined;
45
+ cache: TranscriptContextCache | undefined;
46
+ } {
47
+ const { transcriptPath, derive, tokensMax, cache, now } = input;
48
+ if (!transcriptPath || !derive) return { context: undefined, cache };
49
+ if (typeof tokensMax !== "number" || !Number.isFinite(tokensMax) || tokensMax <= 0) return { context: undefined, cache };
50
+
51
+ let nextCache = cache;
52
+ let usage: ProviderContextUsage | undefined;
53
+ try {
54
+ const stat = statSync(transcriptPath);
55
+ const mtimeMs = stat.mtimeMs;
56
+ const size = stat.size;
57
+ if (cache?.path === transcriptPath && cache.mtimeMs === mtimeMs && cache.size === size) {
58
+ usage = cache.usage;
59
+ } else {
60
+ usage = derive(readFileSync(transcriptPath, "utf8"));
61
+ nextCache = { path: transcriptPath, mtimeMs, size, usage };
62
+ }
63
+ } catch {
64
+ return { context: undefined, cache };
65
+ }
66
+ if (!usage) return { context: undefined, cache: nextCache };
67
+
68
+ return { context: providerContextState(usage.tokensUsed, tokensMax, usage.occurredAt ?? now), cache: nextCache };
69
+ }
70
+
71
+ /** The one ContextState shape for a provider-reported token count against a context window. */
72
+ export function providerContextState(tokensUsed: number, tokensMax: number, lastUpdatedAt: number, confidence: ContextState["confidence"] = "reported"): ContextState {
73
+ return {
74
+ utilization: Math.max(0, Math.min(1, tokensUsed / tokensMax)),
75
+ tokensUsed,
76
+ tokensMax,
77
+ lifecycleState: "working",
78
+ warmTopics: [],
79
+ tasksSinceCompact: 0,
80
+ lastUpdatedAt,
81
+ source: "api",
82
+ confidence,
83
+ };
84
+ }
85
+
86
+ /** The context a provider reports right after it cleared the conversation: an explicit zero, never an omission that leaves the previous usage on the card. */
87
+ export function freshContextState(clearedAt: number, tokensMax?: number): ContextState {
88
+ return {
89
+ utilization: 0,
90
+ ...(tokensMax ? { tokensMax } : {}),
91
+ lifecycleState: "fresh",
92
+ warmTopics: [],
93
+ tasksSinceCompact: 0,
94
+ lastCompactedAt: clearedAt,
95
+ lastUpdatedAt: clearedAt,
96
+ source: "api",
97
+ confidence: "reported",
98
+ };
99
+ }
@@ -0,0 +1,48 @@
1
+ export function walkErrorChain(error: unknown, match: (code: string, name: string, message: string) => boolean): boolean {
2
+ const seen = new Set<unknown>();
3
+ let current: unknown = error;
4
+ while (current && !seen.has(current)) {
5
+ seen.add(current);
6
+ const record = typeof current === "object" && current !== null ? current as Record<string, unknown> : undefined;
7
+ const code = record && typeof record.code === "string" ? record.code : "";
8
+ const name = record && typeof record.name === "string" ? record.name : "";
9
+ const message = record && typeof record.message === "string"
10
+ ? record.message
11
+ : typeof current === "string" ? current : "";
12
+ if (match(code, name, message)) return true;
13
+ current = record?.cause;
14
+ }
15
+ return false;
16
+ }
17
+
18
+ export type TransportErrorClass = "connect" | "ambiguous" | "fatal";
19
+
20
+ // Sent-then-response-lost: a mid-flight reset or a post-send abort. Safe to retry only when idempotent.
21
+ export function isAmbiguousTransportError(error: unknown): boolean {
22
+ return walkErrorChain(error, (code, name, message) =>
23
+ /^(?:ECONNRESET|EPIPE|ETIMEDOUT|ConnectionClosed|ConnectionReset)$/i.test(code) ||
24
+ name === "AbortError" ||
25
+ /socket connection was closed|connection was closed unexpectedly|socket hang up|ECONNRESET|EPIPE|ETIMEDOUT|ConnectionClosed|ConnectionReset|the operation was aborted|aborted/i.test(message));
26
+ }
27
+
28
+ // Connect-phase: the request never reached the relay app. Always safe to retry, for every tool.
29
+ export function isConnectPhaseError(error: unknown): boolean {
30
+ return walkErrorChain(error, (code, _name, message) =>
31
+ /^(?:ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|ECONNABORTED|ConnectionRefused)$/i.test(code) ||
32
+ /ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|Unable to connect|failed to fetch|fetch failed|getaddrinfo|connection refused|ConnectionRefused/i.test(message));
33
+ }
34
+
35
+ export function classifyTransportError(error: unknown): TransportErrorClass {
36
+ // Ambiguous wins over connect: a reset/abort signal anywhere in the chain means the call MAY have
37
+ // executed, which is the more conservative (retry-only-if-idempotent) treatment.
38
+ if (isAmbiguousTransportError(error)) return "ambiguous";
39
+ if (isConnectPhaseError(error)) return "connect";
40
+ return "fatal";
41
+ }
42
+
43
+ // Back-compat: a failure is "retryable transport" if it is either connect-phase or ambiguous (the old
44
+ // single-class predicate #1790 exposed). The retry DECISION now also weighs idempotency for the
45
+ // ambiguous class — see forwardWithConnectionRetry.
46
+ export function isRetryableTransportError(error: unknown): boolean {
47
+ return classifyTransportError(error) !== "fatal";
48
+ }
@@ -0,0 +1,55 @@
1
+ import type { Message } from "@humwick/sdk";
2
+ import type { ProviderDeliverContext, ProviderSessionEvent } from "./adapter";
3
+ import { deliveredBatchReplyRoute } from "./reply-obligation-cache";
4
+
5
+ import { receivesTurnResponseAutomatically } from "./response-routing";
6
+
7
+ type ReplyRoute = NonNullable<ProviderSessionEvent["replyRoute"]>;
8
+
9
+ // #2840 — binds the delivered message that OPENS a provider turn to that turn; only its final answers it.
10
+ // Armed before the input is offered (OpenCode runs the whole turn inside deliver, Codex starts it
11
+ // synchronously), bound at the next turn-start edge, consumed by the final with that turn id. A turn
12
+ // that starts with nothing armed clears the binding, so a later timer turn never inherits a question.
13
+ export class TurnOpenerLedger {
14
+ private pending?: { token: number; route: ReplyRoute | null };
15
+ private bound?: { turnId: string; route: ReplyRoute };
16
+ private tokens = 0;
17
+
18
+ constructor(private readonly selfId: () => string) {}
19
+
20
+ /** These messages are about to open a turn. The dashboard `user` sink is answered by the mirror instead. */
21
+ arm(messages: Message[]): number {
22
+ const route = deliveredBatchReplyRoute(messages, this.selfId());
23
+ this.pending = { token: ++this.tokens, route: route && !receivesTurnResponseAutomatically(route.to) ? route : null };
24
+ return this.tokens;
25
+ }
26
+
27
+ /** One offer: arms it when it opens a turn, hands the adapter a per-send `openTurnFor`, and
28
+ * `settle(false)` disarms an offer that opened nothing (refused, failed, nothing acknowledged). */
29
+ open(messages: Message[], opensTurn: boolean, ctx?: ProviderDeliverContext): { ctx: ProviderDeliverContext; settle: (opened: boolean) => void } {
30
+ const token = opensTurn && messages.length > 0 ? this.arm(messages) : undefined;
31
+ const openTurnFor = (batch: Message[]) => { const sendToken = this.arm(batch); return () => this.disarm(sendToken); };
32
+ // Replaces only this offer's own still-pending arm, so a narrowing never arms a turn the offer did not open.
33
+ const narrowTurnOpener = (batch: Message[]) => { if (token === undefined || this.pending?.token !== token) return () => {}; const narrowed = this.arm(batch); return () => this.disarm(narrowed); };
34
+ return { ctx: { ...ctx, openTurnFor, narrowTurnOpener }, settle: (opened) => { if (token !== undefined && !opened) this.disarm(token); } };
35
+ }
36
+
37
+ /** The offer that armed `token` opened no turn. Never restores. */
38
+ disarm(token: number): void {
39
+ if (this.pending?.token === token) this.pending = undefined;
40
+ }
41
+
42
+ noteTurnStarted(turnId: string): void {
43
+ const route = this.pending?.route;
44
+ this.pending = undefined;
45
+ this.bound = route ? { turnId, route } : undefined;
46
+ }
47
+
48
+ /** The opener the final of `turnId` answers, at most once. */
49
+ take(turnId: string | undefined): ReplyRoute | undefined {
50
+ if (!turnId || this.bound?.turnId !== turnId) return undefined;
51
+ const { route } = this.bound;
52
+ this.bound = undefined;
53
+ return route;
54
+ }
55
+ }