@stigmer/runner 3.40.1 → 3.41.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 (248) hide show
  1. package/README.md +3 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/__test-utils__/execution-record-fixture.d.ts +2 -1
  4. package/dist/__test-utils__/execution-record-fixture.js.map +1 -1
  5. package/dist/activities/call-agent.js +1 -1
  6. package/dist/activities/discover-mcp-server.d.ts +7 -1
  7. package/dist/activities/discover-mcp-server.js +13 -29
  8. package/dist/activities/discover-mcp-server.js.map +1 -1
  9. package/dist/activities/execute-cursor/__test-utils__/fold.d.ts +4 -2
  10. package/dist/activities/execute-cursor/__test-utils__/fold.js +3 -4
  11. package/dist/activities/execute-cursor/__test-utils__/fold.js.map +1 -1
  12. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js +1 -0
  13. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js.map +1 -1
  14. package/dist/activities/execute-cursor/approval-policy.d.ts +1 -29
  15. package/dist/activities/execute-cursor/approval-policy.js +11 -35
  16. package/dist/activities/execute-cursor/approval-policy.js.map +1 -1
  17. package/dist/activities/execute-cursor/boundary-rows.js +4 -4
  18. package/dist/activities/execute-cursor/boundary-rows.js.map +1 -1
  19. package/dist/activities/execute-cursor/translator.d.ts +12 -17
  20. package/dist/activities/execute-cursor/translator.js +13 -30
  21. package/dist/activities/execute-cursor/translator.js.map +1 -1
  22. package/dist/activities/execute-cursor/turn-stream.js +4 -2
  23. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  24. package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.d.ts +11 -1
  25. package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.js +22 -3
  26. package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.js.map +1 -1
  27. package/dist/activities/execute-deep-agent/inline-publisher.d.ts +33 -2
  28. package/dist/activities/execute-deep-agent/inline-publisher.js +33 -7
  29. package/dist/activities/execute-deep-agent/inline-publisher.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/platform-route.d.ts +48 -13
  31. package/dist/activities/execute-deep-agent/platform-route.js +87 -19
  32. package/dist/activities/execute-deep-agent/platform-route.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/streaming-side-effects.d.ts +20 -3
  34. package/dist/activities/execute-deep-agent/streaming-side-effects.js +26 -4
  35. package/dist/activities/execute-deep-agent/streaming-side-effects.js.map +1 -1
  36. package/dist/activities/execute-deep-agent/translator.d.ts +5 -4
  37. package/dist/activities/execute-deep-agent/translator.js +5 -4
  38. package/dist/activities/execute-deep-agent/translator.js.map +1 -1
  39. package/dist/activities/execute-deep-agent/turn-settle.js +6 -6
  40. package/dist/activities/execute-deep-agent/turn-settle.js.map +1 -1
  41. package/dist/activities/execute-deep-agent/turn-setup.js +7 -7
  42. package/dist/activities/execute-deep-agent/turn-setup.js.map +1 -1
  43. package/dist/activities/execute-deep-agent/turn-stream.d.ts +10 -6
  44. package/dist/activities/execute-deep-agent/turn-stream.js +6 -16
  45. package/dist/activities/execute-deep-agent/turn-stream.js.map +1 -1
  46. package/dist/activities/execute-deep-agent/turn.d.ts +4 -1
  47. package/dist/activities/execute-deep-agent/turn.js +15 -6
  48. package/dist/activities/execute-deep-agent/turn.js.map +1 -1
  49. package/dist/activities/workflow-event-activities.js +2 -0
  50. package/dist/activities/workflow-event-activities.js.map +1 -1
  51. package/dist/config.d.ts +9 -0
  52. package/dist/config.js +2 -0
  53. package/dist/config.js.map +1 -1
  54. package/dist/exit-backstop.d.ts +64 -0
  55. package/dist/exit-backstop.js +79 -0
  56. package/dist/exit-backstop.js.map +1 -0
  57. package/dist/harness/terminal-table.d.ts +12 -5
  58. package/dist/harness/terminal-table.js +13 -13
  59. package/dist/harness/terminal-table.js.map +1 -1
  60. package/dist/harness/transcript/builder.d.ts +21 -0
  61. package/dist/harness/transcript/builder.js +53 -31
  62. package/dist/harness/transcript/builder.js.map +1 -1
  63. package/dist/harness/transcript/events.d.ts +22 -26
  64. package/dist/harness/transcript/events.js +6 -5
  65. package/dist/harness/transcript/events.js.map +1 -1
  66. package/dist/harness/turn-context.d.ts +3 -1
  67. package/dist/harness/turn-context.js +5 -3
  68. package/dist/harness/turn-context.js.map +1 -1
  69. package/dist/main.js +10 -4
  70. package/dist/main.js.map +1 -1
  71. package/dist/middleware/cost-advisory.d.ts +6 -3
  72. package/dist/middleware/cost-advisory.js +6 -3
  73. package/dist/middleware/cost-advisory.js.map +1 -1
  74. package/dist/middleware/execution-budget.d.ts +22 -7
  75. package/dist/middleware/execution-budget.js +57 -35
  76. package/dist/middleware/execution-budget.js.map +1 -1
  77. package/dist/middleware/index.d.ts +10 -7
  78. package/dist/middleware/index.js +10 -7
  79. package/dist/middleware/index.js.map +1 -1
  80. package/dist/middleware/types.d.ts +6 -1
  81. package/dist/runner-manager.js +3 -2
  82. package/dist/runner-manager.js.map +1 -1
  83. package/dist/runner.d.ts +25 -0
  84. package/dist/runner.js +41 -14
  85. package/dist/runner.js.map +1 -1
  86. package/dist/shared/approval-policy.d.ts +23 -13
  87. package/dist/shared/approval-policy.js +47 -14
  88. package/dist/shared/approval-policy.js.map +1 -1
  89. package/dist/shared/caller-identity.d.ts +4 -5
  90. package/dist/shared/caller-identity.js +4 -5
  91. package/dist/shared/caller-identity.js.map +1 -1
  92. package/dist/shared/channel-attachment.js +3 -2
  93. package/dist/shared/channel-attachment.js.map +1 -1
  94. package/dist/shared/connect-backfill.d.ts +2 -1
  95. package/dist/shared/connect-backfill.js +2 -2
  96. package/dist/shared/connect-backfill.js.map +1 -1
  97. package/dist/shared/mcp-resolver.d.ts +10 -1
  98. package/dist/shared/mcp-resolver.js +11 -2
  99. package/dist/shared/mcp-resolver.js.map +1 -1
  100. package/dist/shared/memory-attachment.js +3 -2
  101. package/dist/shared/memory-attachment.js.map +1 -1
  102. package/dist/shared/model-error.d.ts +25 -3
  103. package/dist/shared/model-error.js +78 -10
  104. package/dist/shared/model-error.js.map +1 -1
  105. package/dist/shared/platform-server-address.d.ts +71 -0
  106. package/dist/shared/platform-server-address.js +103 -0
  107. package/dist/shared/platform-server-address.js.map +1 -0
  108. package/dist/shared/synthesized-attachment.d.ts +0 -5
  109. package/dist/shared/synthesized-attachment.js +0 -12
  110. package/dist/shared/synthesized-attachment.js.map +1 -1
  111. package/dist/shared/tool-rounds.d.ts +51 -23
  112. package/dist/shared/tool-rounds.js +67 -24
  113. package/dist/shared/tool-rounds.js.map +1 -1
  114. package/dist/shared/workspace/platform-dir.d.ts +15 -0
  115. package/dist/shared/workspace/platform-dir.js +18 -1
  116. package/dist/shared/workspace/platform-dir.js.map +1 -1
  117. package/dist/shared/workspace/stigmer-link.d.ts +21 -14
  118. package/dist/shared/workspace/stigmer-link.js +91 -29
  119. package/dist/shared/workspace/stigmer-link.js.map +1 -1
  120. package/dist/tools/url-guard.d.ts +5 -1
  121. package/dist/tools/url-guard.js +9 -2
  122. package/dist/tools/url-guard.js.map +1 -1
  123. package/dist/worker.d.ts +11 -2
  124. package/dist/worker.js +1 -1
  125. package/dist/worker.js.map +1 -1
  126. package/dist/workflow-engine/tasks/human-input.d.ts +16 -0
  127. package/dist/workflow-engine/tasks/human-input.js +79 -37
  128. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  129. package/dist/workflow-engine/types.d.ts +14 -4
  130. package/dist/workflow-engine/types.js.map +1 -1
  131. package/dist/workflows/call-agent-orchestrator.d.ts +13 -0
  132. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  133. package/dist/workflows/engine-core.d.ts +25 -0
  134. package/dist/workflows/engine-core.js.map +1 -1
  135. package/dist/workflows/execute-from-execution.d.ts +3 -0
  136. package/dist/workflows/execute-from-execution.js.map +1 -1
  137. package/dist/workflows/human-input-orchestrator.d.ts +9 -12
  138. package/dist/workflows/human-input-orchestrator.js +12 -44
  139. package/dist/workflows/human-input-orchestrator.js.map +1 -1
  140. package/package.json +5 -5
  141. package/src/__test-utils__/config-fixture.ts +1 -0
  142. package/src/__test-utils__/execution-record-fixture.ts +2 -1
  143. package/src/__test-utils__/harness-contract/runtime-contract.ts +5 -3
  144. package/src/__test-utils__/test-gate.ts +8 -0
  145. package/src/__tests__/exit-backstop.test.ts +91 -0
  146. package/src/__tests__/golden-e2e.test.ts +80 -62
  147. package/src/__tests__/history-encryption-e2e.test.ts +51 -27
  148. package/src/__tests__/runner.test.ts +60 -2
  149. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  150. package/src/activities/__tests__/discover-mcp-server.hang.test.ts +5 -1
  151. package/src/activities/__tests__/discover-mcp-server.test.ts +90 -49
  152. package/src/activities/__tests__/workflow-event-activities.test.ts +28 -7
  153. package/src/activities/call-agent.ts +1 -1
  154. package/src/activities/discover-mcp-server.ts +27 -37
  155. package/src/activities/execute-cursor/__test-utils__/fold.ts +6 -4
  156. package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +1 -0
  157. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +4 -29
  158. package/src/activities/execute-cursor/__tests__/hermetic/bypassed-tool-call.test.ts +120 -0
  159. package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +5 -5
  160. package/src/activities/execute-cursor/__tests__/hermetic/goldens/file-review-capture.status.json +0 -2
  161. package/src/activities/execute-cursor/__tests__/hermetic/goldens/resolution-error.status.json +1 -1
  162. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.error-delta.status.json +0 -3
  163. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.output-first.status.json +0 -2
  164. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.status.json +0 -2
  165. package/src/activities/execute-cursor/__tests__/hermetic/goldens/stall.status.json +0 -2
  166. package/src/activities/execute-cursor/__tests__/hermetic/goldens/unattributed-hook-block.status.json +0 -3
  167. package/src/activities/execute-cursor/__tests__/hermetic/shell-output-delta.test.ts +9 -7
  168. package/src/activities/execute-cursor/__tests__/hermetic/thrown-error-arms.test.ts +6 -1
  169. package/src/activities/execute-cursor/__tests__/hitl-resume-history.test.ts +8 -11
  170. package/src/activities/execute-cursor/__tests__/sequential-gate-resume.test.ts +1 -3
  171. package/src/activities/execute-cursor/__tests__/translator.test.ts +68 -15
  172. package/src/activities/execute-cursor/approval-policy.ts +11 -36
  173. package/src/activities/execute-cursor/boundary-rows.ts +3 -3
  174. package/src/activities/execute-cursor/translator.ts +15 -32
  175. package/src/activities/execute-cursor/turn-stream.ts +4 -1
  176. package/src/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.ts +33 -2
  177. package/src/activities/execute-deep-agent/__tests__/adapter.test.ts +1 -0
  178. package/src/activities/execute-deep-agent/__tests__/hermetic/file-review-capture.test.ts +4 -0
  179. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/file-review-capture.status.json +1 -1
  180. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/inline-artifact.turn1.status.json +1 -1
  181. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/inline-artifact.turn2.status.json +1 -1
  182. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/recursion-limit.persisted.status.json +8 -73
  183. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/resolution-error.status.json +1 -1
  184. package/src/activities/execute-deep-agent/__tests__/hermetic/inline-artifact-across-gate.test.ts +17 -0
  185. package/src/activities/execute-deep-agent/__tests__/hermetic/recursion-limit.test.ts +86 -26
  186. package/src/activities/execute-deep-agent/__tests__/hermetic/resolution-error.test.ts +9 -5
  187. package/src/activities/execute-deep-agent/__tests__/hermetic/thrown-turn-drains-publishes.test.ts +159 -0
  188. package/src/activities/execute-deep-agent/__tests__/inline-publisher.test.ts +84 -0
  189. package/src/activities/execute-deep-agent/__tests__/platform-route.test.ts +47 -10
  190. package/src/activities/execute-deep-agent/__tests__/streaming-side-effects.test.ts +34 -0
  191. package/src/activities/execute-deep-agent/__tests__/turn-stream.test.ts +13 -5
  192. package/src/activities/execute-deep-agent/inline-publisher.ts +47 -7
  193. package/src/activities/execute-deep-agent/platform-route.ts +109 -20
  194. package/src/activities/execute-deep-agent/streaming-side-effects.ts +27 -4
  195. package/src/activities/execute-deep-agent/translator.ts +5 -4
  196. package/src/activities/execute-deep-agent/turn-settle.ts +6 -6
  197. package/src/activities/execute-deep-agent/turn-setup.ts +7 -7
  198. package/src/activities/execute-deep-agent/turn-stream.ts +15 -22
  199. package/src/activities/execute-deep-agent/turn.ts +16 -7
  200. package/src/activities/workflow-event-activities.ts +2 -0
  201. package/src/config.ts +11 -0
  202. package/src/exit-backstop.ts +97 -0
  203. package/src/harness/README.md +5 -5
  204. package/src/harness/terminal-table.ts +13 -13
  205. package/src/harness/transcript/__tests__/builder.test.ts +93 -21
  206. package/src/harness/transcript/builder.ts +53 -30
  207. package/src/harness/transcript/events.ts +22 -27
  208. package/src/harness/turn-context.ts +5 -3
  209. package/src/main.ts +9 -5
  210. package/src/middleware/__tests__/execution-budget.test.ts +181 -103
  211. package/src/middleware/cost-advisory.ts +6 -3
  212. package/src/middleware/execution-budget.ts +60 -34
  213. package/src/middleware/index.ts +10 -7
  214. package/src/middleware/types.ts +6 -1
  215. package/src/runner-manager.ts +3 -2
  216. package/src/runner.ts +57 -15
  217. package/src/shared/__tests__/approval-policy.test.ts +41 -0
  218. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  219. package/src/shared/__tests__/connect-backfill.test.ts +15 -11
  220. package/src/shared/__tests__/mcp-resolver.test.ts +72 -10
  221. package/src/shared/__tests__/model-error.test.ts +71 -3
  222. package/src/shared/__tests__/platform-server-address.test.ts +122 -0
  223. package/src/shared/__tests__/tool-rounds.test.ts +64 -14
  224. package/src/shared/approval-policy.ts +57 -14
  225. package/src/shared/caller-identity.ts +4 -5
  226. package/src/shared/channel-attachment.ts +2 -2
  227. package/src/shared/connect-backfill.ts +5 -1
  228. package/src/shared/mcp-resolver.ts +11 -1
  229. package/src/shared/memory-attachment.ts +2 -2
  230. package/src/shared/model-error.ts +87 -9
  231. package/src/shared/platform-server-address.ts +123 -0
  232. package/src/shared/synthesized-attachment.ts +0 -12
  233. package/src/shared/tool-rounds.ts +70 -24
  234. package/src/shared/workspace/__tests__/stigmer-link.test.ts +107 -10
  235. package/src/shared/workspace/platform-dir.ts +19 -1
  236. package/src/shared/workspace/stigmer-link.ts +99 -29
  237. package/src/tools/__tests__/url-guard.test.ts +19 -1
  238. package/src/tools/url-guard.ts +9 -1
  239. package/src/worker.ts +12 -2
  240. package/src/workflow-engine/__tests__/golden-execution.test.ts +2 -1
  241. package/src/workflow-engine/__tests__/loader.test.ts +1 -1
  242. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +133 -57
  243. package/src/workflow-engine/tasks/human-input.ts +92 -42
  244. package/src/workflow-engine/types.ts +16 -4
  245. package/src/workflows/call-agent-orchestrator.ts +7 -0
  246. package/src/workflows/engine-core.ts +18 -0
  247. package/src/workflows/execute-from-execution.ts +2 -1
  248. package/src/workflows/human-input-orchestrator.ts +15 -56
@@ -30,9 +30,18 @@
30
30
  *
31
31
  * WHEN IT IS MOUNTED
32
32
  * ------------------
33
- * On the turns where the workspace link points at the platform dir — the
34
- * turns whose skill or attachment phase made it — so what the agent can
35
- * reach is exactly what the link exposes to the shell on the same turn.
33
+ * On every turn: `.stigmer/` is the platform's name whether or not the turn
34
+ * mounted anything, so a write there never reaches the workspace. Before
35
+ * #1123 the route existed only on linked turns, and a write under `.stigmer/`
36
+ * on any other turn made a real directory in the user's tree that the next
37
+ * link displaced. What the route exposes follows the link, so the file tools
38
+ * reach exactly what the shell reaches on the same turn:
39
+ * - on a turn whose skill or attachment phase made the link, the platform dir
40
+ * ({@link PlatformContentBackend});
41
+ * - on any other turn, nothing ({@link NoPlatformContentBackend}): the dir
42
+ * still holds earlier turns' skills and inputs, which this turn's shell
43
+ * cannot reach, so the file tools must not either. The root listing then
44
+ * leaves `.stigmer/` out, as the tree does.
36
45
  * The link itself stays: shell commands (a skill's own scripts among them)
37
46
  * run in the workspace and resolve `.stigmer/…` through it.
38
47
  */
@@ -40,10 +49,16 @@
40
49
  import { CompositeBackend, FilesystemBackend } from "deepagents";
41
50
  import type {
42
51
  AnyBackendProtocol,
52
+ BackendProtocolV2,
43
53
  DeleteResult,
44
54
  EditResult,
55
+ FileDownloadResponse,
45
56
  FileUploadResponse,
57
+ GlobResult,
58
+ GrepResult,
46
59
  LsResult,
60
+ ReadRawResult,
61
+ ReadResult,
47
62
  WriteResult,
48
63
  } from "deepagents";
49
64
  import { STIGMER_LOCAL_STATE_DIR, stigmerSymlinkPointsAt } from "../../shared/workspace/stigmer-link.js";
@@ -51,14 +66,25 @@ import { STIGMER_LOCAL_STATE_DIR, stigmerSymlinkPointsAt } from "../../shared/wo
51
66
  /** The route prefix, with the trailing slash `CompositeBackend` needs to match `/.stigmer/x` but never `/.stigmerx`. */
52
67
  export const PLATFORM_ROUTE_PREFIX = `/${STIGMER_LOCAL_STATE_DIR}/`;
53
68
 
69
+ /** The path as the agent named it: the route hands its backend the path with the prefix stripped. */
70
+ function agentPath(routedPath: string): string {
71
+ return `${PLATFORM_ROUTE_PREFIX.slice(0, -1)}${routedPath}`;
72
+ }
73
+
54
74
  function readOnly(routedPath: string): string {
55
- const agentPath = `${PLATFORM_ROUTE_PREFIX.slice(0, -1)}${routedPath}`;
56
75
  return (
57
- `Error: '${agentPath}' is platform content (skills, attached inputs, the approved plan) ` +
76
+ `Error: '${agentPath(routedPath)}' is platform content (skills, attached inputs, the approved plan) ` +
58
77
  "and is read-only; write your own files in the workspace"
59
78
  );
60
79
  }
61
80
 
81
+ function noPlatformContent(routedPath: string): string {
82
+ return (
83
+ `Error: '${agentPath(routedPath)}' does not exist: this turn has no platform content ` +
84
+ "(no skills or attached inputs)"
85
+ );
86
+ }
87
+
62
88
  /** The platform dir, virtual-rooted like every native backend, refusing every mutation. */
63
89
  export class PlatformContentBackend extends FilesystemBackend {
64
90
  constructor(platformDir: string) {
@@ -83,17 +109,80 @@ export class PlatformContentBackend extends FilesystemBackend {
83
109
  }
84
110
 
85
111
  /**
86
- * The workspace backend with the platform mounted at {@link PLATFORM_ROUTE_PREFIX}.
112
+ * The `.stigmer/` route on a turn that exposes no platform content: nothing
113
+ * to read, list or find, and every mutation refused with the same message
114
+ * the platform dir's route gives. It stands on no filesystem, so it cannot
115
+ * create or reveal anything, and `ls` answers "does not exist" rather than
116
+ * an empty listing because the root listing leaves `.stigmer/` out.
117
+ */
118
+ export class NoPlatformContentBackend implements BackendProtocolV2 {
119
+ ls(path: string): LsResult {
120
+ return { error: noPlatformContent(path) };
121
+ }
122
+
123
+ read(filePath: string): ReadResult {
124
+ return { error: noPlatformContent(filePath) };
125
+ }
126
+
127
+ readRaw(filePath: string): ReadRawResult {
128
+ return { error: noPlatformContent(filePath) };
129
+ }
130
+
131
+ grep(): GrepResult {
132
+ return { matches: [] };
133
+ }
134
+
135
+ glob(): GlobResult {
136
+ return { files: [] };
137
+ }
138
+
139
+ write(filePath: string): WriteResult {
140
+ return { error: readOnly(filePath) };
141
+ }
142
+
143
+ edit(filePath: string): EditResult {
144
+ return { error: readOnly(filePath) };
145
+ }
146
+
147
+ delete(filePath: string): DeleteResult {
148
+ return { error: readOnly(filePath) };
149
+ }
150
+
151
+ uploadFiles(files: Array<[string, Uint8Array]>): FileUploadResponse[] {
152
+ return files.map(([path]) => ({ path, error: "permission_denied" }));
153
+ }
154
+
155
+ downloadFiles(paths: string[]): FileDownloadResponse[] {
156
+ return paths.map((path) => ({ path, content: null, error: "file_not_found" }));
157
+ }
158
+ }
159
+
160
+ /**
161
+ * The workspace backend with `.stigmer/` routed to {@link PLATFORM_ROUTE_PREFIX}'s
162
+ * backend for this turn.
87
163
  *
88
164
  * One behaviour differs from the stock router: listing `/`. The composite
89
- * adds each route to the root listing, and the workspace backend already
90
- * lists `.stigmer/` by following the link, so the stock listing names the
91
- * same directory twice; the root listing is de-duplicated by path.
165
+ * adds each route to the root listing whatever the route holds. With the
166
+ * platform exposed, the workspace backend also lists `.stigmer/` by following
167
+ * the link, so the stock listing names the same directory twice and is
168
+ * de-duplicated by path. With nothing exposed, `.stigmer/` is left out of the
169
+ * root listing altogether: the route has nothing to show, and a real
170
+ * `.stigmer` the workspace may hold is unreachable behind it.
92
171
  */
93
172
  export class PlatformRoutedBackend extends CompositeBackend {
173
+ private readonly exposesPlatform: boolean;
174
+
175
+ constructor(backend: AnyBackendProtocol, platform: PlatformContentBackend | NoPlatformContentBackend) {
176
+ super(backend, { [PLATFORM_ROUTE_PREFIX]: platform });
177
+ this.exposesPlatform = platform instanceof PlatformContentBackend;
178
+ }
179
+
94
180
  override async ls(path: string): Promise<LsResult> {
95
181
  const result = await super.ls(path);
96
182
  if (path !== "/" || !result.files) return result;
183
+ if (!this.exposesPlatform) {
184
+ return { ...result, files: result.files.filter((entry) => entry.path !== PLATFORM_ROUTE_PREFIX) };
185
+ }
97
186
  const seen = new Set<string>();
98
187
  return {
99
188
  ...result,
@@ -114,21 +203,21 @@ export interface PlatformRouteOptions {
114
203
  }
115
204
 
116
205
  /**
117
- * Mount the platform route over `backend` when this turn exposes platform
118
- * content; otherwise hand `backend` back unchanged. `execute` and the
119
- * sandbox identity stay the workspace backend's (`CompositeBackend` delegates
120
- * both to its default), so a shell-capable graph keeps its `execute` tool and
121
- * a plan-mode graph stays without one.
206
+ * Mount the `.stigmer/` route over `backend` for this turn: the platform dir
207
+ * when the workspace link points at it, the empty route otherwise. Only a
208
+ * session without a platform dir gets `backend` back unchanged. `execute`
209
+ * and the sandbox identity stay the workspace backend's (`CompositeBackend`
210
+ * delegates both to its default), so a shell-capable graph keeps its
211
+ * `execute` tool and a plan-mode graph stays without one.
122
212
  */
123
213
  export async function mountPlatformRoute(
124
214
  backend: AnyBackendProtocol,
125
215
  options: PlatformRouteOptions,
126
216
  ): Promise<AnyBackendProtocol> {
127
217
  const { workspaceDir, platformDir } = options;
128
- if (platformDir === undefined || !(await stigmerSymlinkPointsAt(workspaceDir, platformDir))) {
129
- return backend;
130
- }
131
- return new PlatformRoutedBackend(backend, {
132
- [PLATFORM_ROUTE_PREFIX]: new PlatformContentBackend(platformDir),
133
- });
218
+ if (platformDir === undefined) return backend;
219
+ const platform = (await stigmerSymlinkPointsAt(workspaceDir, platformDir))
220
+ ? new PlatformContentBackend(platformDir)
221
+ : new NoPlatformContentBackend();
222
+ return new PlatformRoutedBackend(backend, platform);
134
223
  }
@@ -16,8 +16,17 @@
16
16
  * Until #1096 the same correlation also fed the write-back
17
17
  * coordinator's per-file commit (`onFileModified`); write-back is
18
18
  * finalize-only now, the turn runtime's, so publishing is this trigger's one
19
- * effect. The loop (`turn-stream.ts`) drains {@link pendingPublishPromises}
20
- * in the settle.
19
+ * effect.
20
+ *
21
+ * One instance per turn, owned by the turn's transcript
22
+ * (`turn-stream.ts` `createDeepAgentTranscript`) and not by the stream loop,
23
+ * so the in-flight publishes outlive a stream that throws: the settle
24
+ * drains them before its safety net, and the turn's `finally`
25
+ * (`turn.ts`) drains them on every exit, a thrown turn included, before the
26
+ * runtime writes the terminal status (stigmer#1116). Until then the loop
27
+ * built this and handed the list out only on its return values, so a turn
28
+ * that threw left its uploads to land on a status already persisted for the
29
+ * last time.
21
30
  */
22
31
 
23
32
  import type { TranscriptEvent } from "../../harness/transcript/events.js";
@@ -33,12 +42,26 @@ export class StreamingSideEffects {
33
42
  private readonly inputCache = new Map<string, CachedToolInput>();
34
43
  private readonly inlinePublisher: InlinePublisher | undefined;
35
44
 
36
- readonly pendingPublishPromises: Promise<void>[] = [];
45
+ private readonly pending: Promise<void>[] = [];
37
46
 
38
47
  constructor(opts: { inlinePublisher?: InlinePublisher }) {
39
48
  this.inlinePublisher = opts.inlinePublisher;
40
49
  }
41
50
 
51
+ /** The publishes fired so far, in order; each resolves (a publish logs and swallows its own failure). */
52
+ get pendingPublishPromises(): readonly Promise<void>[] {
53
+ return this.pending;
54
+ }
55
+
56
+ /**
57
+ * Wait for every publish fired so far to land on the status. Idempotent:
58
+ * a second call over settled publishes returns at once. Bounded by the
59
+ * artifact storage's own per-call timeouts, as every drain has been.
60
+ */
61
+ async drainPublishes(): Promise<void> {
62
+ if (this.pending.length > 0) await Promise.allSettled(this.pending);
63
+ }
64
+
42
65
  onEvent(event: TranscriptEvent): void {
43
66
  if (!this.inlinePublisher) return;
44
67
 
@@ -54,7 +77,7 @@ export class StreamingSideEffects {
54
77
  if (!isFileModifyingTool(cached.toolName)) return;
55
78
  const filePath = extractFilePath(cached.input);
56
79
  if (!filePath) return;
57
- this.pendingPublishPromises.push(this.inlinePublisher.publish(filePath));
80
+ this.pending.push(this.inlinePublisher.publish(filePath));
58
81
  }
59
82
  }
60
83
  }
@@ -30,10 +30,11 @@
30
30
  * mode asks the workspace's git state, asynchronously;
31
31
  * `middleware/approval-gate.ts`), and duplicating them would be a second copy
32
32
  * of the decision — exactly the drift one writer per fact exists to end. So
33
- * native emits no `tool_started.gate`; the held call reaches the transcript
34
- * as `approval_proposed` from the post-stream seed. Until #1097 the builder
35
- * carried a copy of the decision that gated MCP tools only, a path
36
- * production never took.
33
+ * the held call reaches the transcript as `approval_proposed` from the
34
+ * post-stream seed, the one writer of a row's approval fields; the Cursor
35
+ * harness answers the same way since #1117. Until #1097 the builder carried a
36
+ * copy of the decision that gated MCP tools only, a path production never
37
+ * took.
37
38
  *
38
39
  * Attribution and provenance are answered for ROOT-scope calls only: a
39
40
  * sub-agent's rows carry none on either harness today (#1133), and one
@@ -59,12 +59,12 @@ export async function settleDeepAgentTurn(deps: DeepAgentSettleDeps): Promise<Tu
59
59
  const { executionId } = input;
60
60
  const { status } = sink;
61
61
 
62
- // Drain the publishes the stream fired (fire-and-forget completions); the
63
- // safety net that scans tool calls for files the stream missed runs only
64
- // on a finished turn, as it always did.
65
- if (stream.pendingPublishPromises.length > 0) {
66
- await Promise.allSettled(stream.pendingPublishPromises);
67
- }
62
+ // Drain the publishes the stream fired (fire-and-forget completions) BEFORE
63
+ // the safety net: its publisher skips a file only when the file's row is
64
+ // already on the status, so an upload still in flight would be sent twice.
65
+ // The net scans tool calls for files the stream missed and runs only on a
66
+ // finished turn, as it always did.
67
+ await transcript.sideEffects.drainPublishes();
68
68
  if (stream.reason === "completed") {
69
69
  try {
70
70
  await autoPublishWrittenFiles(status, transcript.publisher);
@@ -71,7 +71,7 @@ import { visionPromptInfoOf } from "../../shared/prompt-sections.js";
71
71
  import { findApprovedPlanPath } from "../../shared/implement-plan-prompt.js";
72
72
  import type { RecalledMemoriesContent } from "../../shared/recalled-memories.js";
73
73
  import { toLangChainImageBlocks } from "../../shared/attachment-vision.js";
74
- import { resolveRecursionLimit, UNBOUNDED_ADVISORY_RECURSION_LIMIT } from "../../shared/tool-rounds.js";
74
+ import { backstopRecursionLimit, resolveToolRoundLimit } from "../../shared/tool-rounds.js";
75
75
  import { jsonSchemaToZod } from "../../shared/json-schema-to-zod.js";
76
76
  import { CasCaptureObserver } from "./cas-capture-observer.js";
77
77
  import { createCasCaptureBackend } from "./cas-capture-backend.js";
@@ -436,10 +436,10 @@ export async function buildEngine(
436
436
  : null;
437
437
 
438
438
  const maxCostUsd = execConfig?.maxCostUsd ?? 0;
439
- const recursionLimit = resolveRecursionLimit(execConfig?.maxToolRounds);
439
+ const toolRoundLimit = resolveToolRoundLimit(execConfig?.maxToolRounds);
440
440
  const { middleware, costAdvisory } = buildMiddlewareStack({
441
441
  loopDetection: { historySize: 20, consecutiveThreshold: 7, totalThreshold: 20 },
442
- executionBudget: { recursionLimit: recursionLimit ?? UNBOUNDED_ADVISORY_RECURSION_LIMIT, warningPct: 80 },
442
+ executionBudget: { maxToolRounds: toolRoundLimit, warningPct: 80 },
443
443
  toolTruncation: { maxChars: execConfig?.maxToolResultChars || 30_000 },
444
444
  costAdvisory: maxCostUsd > 0
445
445
  ? {
@@ -546,10 +546,10 @@ export async function buildEngine(
546
546
 
547
547
  const langgraphConfig: Record<string, unknown> = {
548
548
  configurable: { thread_id: input.threadId },
549
- // Hard enforcement of max_tool_rounds (null = unlimited): when the graph
550
- // exhausts this, the stream throws GraphRecursionError and the adapter
551
- // ends the turn `tool_call_limit` with the work checkpointed.
552
- ...(recursionLimit !== null ? { recursionLimit } : {}),
549
+ // max_tool_rounds is enforced by the execution budget middleware, round
550
+ // by round; this is only its backstop against a graph that loops without
551
+ // calling the model (`shared/tool-rounds.ts`). Unset = unlimited = none.
552
+ ...(toolRoundLimit !== null ? { recursionLimit: backstopRecursionLimit(toolRoundLimit) } : {}),
553
553
  };
554
554
 
555
555
  console.log(
@@ -89,8 +89,6 @@ export interface DeepAgentStreamResult {
89
89
  readonly eventsProcessed: number;
90
90
  /** The graph's final state (`structuredResponse` rides it); absent unless the stream completed and the output resolved in time. */
91
91
  readonly runOutput: Record<string, unknown> | undefined;
92
- /** Inline publishes fired mid-turn, drained by the settle. */
93
- readonly pendingPublishPromises: readonly Promise<void>[];
94
92
  }
95
93
 
96
94
  /**
@@ -98,14 +96,19 @@ export interface DeepAgentStreamResult {
98
96
  * the engine's events into canonical ones (over the gate's posture, so every
99
97
  * tool start carries its attribution and gate answer), and the publisher
100
98
  * that registers artifacts on the runtime's builder (`sink.transcript`, so
101
- * a published artifact still dirties the next persist). The builder itself
102
- * is the runtime's, one per turn, and is read from the sink where it is
103
- * needed. Built by the adapter's turn once and shared by the stream and the
104
- * settle.
99
+ * a published artifact still dirties the next persist) and checks the rows
100
+ * already on `sink.status` before uploading, so an earlier turn's unchanged
101
+ * file is not sent again; and the side effects that fire the publisher
102
+ * mid-turn and hold its in-flight uploads until they are drained. The
103
+ * builder itself is the runtime's, one per turn, and is read from the sink
104
+ * where it is needed. Built by the adapter's turn once and shared by the
105
+ * stream, the settle and the turn's `finally`, which drains the publishes
106
+ * on every exit (`streaming-side-effects.ts`).
105
107
  */
106
108
  export interface DeepAgentTranscript {
107
109
  readonly translator: DeepAgentTranslator;
108
110
  readonly publisher: InlinePublisher;
111
+ readonly sideEffects: StreamingSideEffects;
109
112
  }
110
113
 
111
114
  export function createDeepAgentTranscript(
@@ -119,9 +122,10 @@ export function createDeepAgentTranscript(
119
122
  workspaceBackend: workspace.backend,
120
123
  artifactStorage: input.artifactStorage,
121
124
  artifacts: sink.transcript,
125
+ record: sink.status,
122
126
  executionId: input.executionId,
123
127
  });
124
- return { translator, publisher };
128
+ return { translator, publisher, sideEffects: new StreamingSideEffects({ inlinePublisher: publisher }) };
125
129
  }
126
130
 
127
131
  /**
@@ -166,12 +170,11 @@ export interface DeepAgentStreamDeps {
166
170
  export async function consumeDeepAgentStream(deps: DeepAgentStreamDeps): Promise<DeepAgentStreamResult> {
167
171
  const { input, sink, engine, graphInput, transcript } = deps;
168
172
  const { executionId } = input;
169
- const { translator, publisher } = transcript;
173
+ const { translator, sideEffects } = transcript;
170
174
  const builder = sink.transcript;
171
175
 
172
176
  const scheduler = new StreamingUpdateScheduler(loadStreamingConfig());
173
177
  const recorder = createV3EventRecorder(executionId, process.env.V3_EVENT_RECORD_DIR);
174
- const sideEffects = new StreamingSideEffects({ inlinePublisher: publisher });
175
178
  const abortController = new AbortController();
176
179
  let forceAbort: ReturnType<typeof setTimeout> | undefined;
177
180
  const abortRun = (): void => {
@@ -188,12 +191,7 @@ export async function consumeDeepAgentStream(deps: DeepAgentStreamDeps): Promise
188
191
  /** The stop, taken at a boundary: abort the run now and settle. */
189
192
  const interrupted = (): DeepAgentStreamResult => {
190
193
  abortRun();
191
- return {
192
- reason: "interrupted",
193
- eventsProcessed,
194
- runOutput: undefined,
195
- pendingPublishPromises: sideEffects.pendingPublishPromises,
196
- };
194
+ return { reason: "interrupted", eventsProcessed, runOutput: undefined };
197
195
  };
198
196
 
199
197
  try {
@@ -254,18 +252,13 @@ export async function consumeDeepAgentStream(deps: DeepAgentStreamDeps): Promise
254
252
 
255
253
  if (builder.awaitingApproval) {
256
254
  console.log(`[turn-stream] execution=${executionId} stream ended at a gate; awaiting approval`);
257
- return {
258
- reason: "awaiting_approval",
259
- eventsProcessed,
260
- runOutput: undefined,
261
- pendingPublishPromises: sideEffects.pendingPublishPromises,
262
- };
255
+ return { reason: "awaiting_approval", eventsProcessed, runOutput: undefined };
263
256
  }
264
257
 
265
258
  console.log(`[turn-stream] execution=${executionId} stream finished — processed ${eventsProcessed} events`);
266
259
  const runOutput = await extractRunOutput(run, executionId);
267
260
  sink.recordActivity();
268
- return { reason: "completed", eventsProcessed, runOutput, pendingPublishPromises: sideEffects.pendingPublishPromises };
261
+ return { reason: "completed", eventsProcessed, runOutput };
269
262
  } finally {
270
263
  sink.stopSignal.removeEventListener("abort", onStop);
271
264
  if (forceAbort !== undefined) clearTimeout(forceAbort);
@@ -24,7 +24,10 @@
24
24
  * other exception — a provider error unwrapped from LangChain's middleware
25
25
  * wrapper, an MCP connect failure, an empty stream — is `failed` on the
26
26
  * `internal` surface with `describeExecutionError`'s sentence as
27
- * `status.error` whole. Never branches on why `stopSignal`
27
+ * `status.error` whole, and it returns only after the inline publishes the
28
+ * turn already fired have landed on the status (the drain in the `finally`),
29
+ * so an artifact the agent wrote before the throw is on the terminal status
30
+ * the runtime then persists (stigmer#1116). Never branches on why `stopSignal`
28
31
  * aborted, never writes a phase or a terminal copy, never imports
29
32
  * `@temporalio/*` (`__tests__/adapter-is-temporal-free.test.ts`).
30
33
  *
@@ -44,7 +47,7 @@ import type { TurnInput, TurnOutcome, TurnSink } from "../../harness/types.js";
44
47
  import type { McpConnectionResult } from "../../shared/mcp-manager.js";
45
48
  import { tryInferProvider } from "../../shared/llm-proxy.js";
46
49
  import { describeExecutionError } from "../../shared/model-error.js";
47
- import { isGraphRecursionError } from "../../shared/tool-rounds.js";
50
+ import { isToolCallBudgetStop } from "../../shared/tool-rounds.js";
48
51
  import {
49
52
  buildDeepAgentWorkspace,
50
53
  buildEngine,
@@ -55,7 +58,7 @@ import {
55
58
  resolveModelName,
56
59
  type DeepAgentAdapterConfig,
57
60
  } from "./turn-setup.js";
58
- import { consumeDeepAgentStream, createDeepAgentTranscript } from "./turn-stream.js";
61
+ import { consumeDeepAgentStream, createDeepAgentTranscript, type DeepAgentTranscript } from "./turn-stream.js";
59
62
  import { settleDeepAgentTurn } from "./turn-settle.js";
60
63
 
61
64
  export async function runDeepAgentTurn(input: TurnInput, sink: TurnSink, config: DeepAgentAdapterConfig): Promise<TurnOutcome> {
@@ -64,6 +67,7 @@ export async function runDeepAgentTurn(input: TurnInput, sink: TurnSink, config:
64
67
  let checkpointer: BaseCheckpointSaver | undefined;
65
68
  let connection: McpConnectionResult | undefined;
66
69
  let modelName: string | undefined;
70
+ let transcript: DeepAgentTranscript | undefined;
67
71
 
68
72
  try {
69
73
  modelName = await resolveModelName(input);
@@ -83,12 +87,16 @@ export async function runDeepAgentTurn(input: TurnInput, sink: TurnSink, config:
83
87
  const graphInput = await composeGraphInput(input, engine);
84
88
  sink.recordActivity();
85
89
 
86
- const transcript = createDeepAgentTranscript(input, sink, engine, workspace);
90
+ transcript = createDeepAgentTranscript(input, sink, engine, workspace);
87
91
  const stream = await consumeDeepAgentStream({ input, sink, engine, graphInput, transcript });
88
92
  return await settleDeepAgentTurn({ input, sink, engine, transcript, stream });
89
93
  } catch (err) {
90
94
  return classifyThrown(err, input, config, modelName);
91
95
  } finally {
96
+ // First, on every exit: the runtime persists the terminal status as soon
97
+ // as this returns, so an upload still in flight must land before then.
98
+ // A settled turn already drained in its settle; this is then a no-op.
99
+ await transcript?.sideEffects.drainPublishes();
92
100
  await closeQuietly(input.executionId, "MCP connection", () => connection?.client.close());
93
101
  // Only the durable sqlite saver holds an OS handle (an open DB file);
94
102
  // memory/http savers have no close(), so this is duck-typed.
@@ -98,8 +106,9 @@ export async function runDeepAgentTurn(input: TurnInput, sink: TurnSink, config:
98
106
  }
99
107
 
100
108
  /**
101
- * An exception that escaped the turn, classified. The graph's recursion
102
- * limit is the budget's own signal (`shared/tool-rounds.ts`), not an error:
109
+ * An exception that escaped the turn, classified. The tool-call budget's stop
110
+ * (the execution budget's round limit, or its recursion-limit backstop;
111
+ * `shared/tool-rounds.ts`) is the budget's own signal, not an error:
103
112
  * the work is checkpointed and the conversation continues on the next
104
113
  * message. Anything else is described by `describeExecutionError` (a model
105
114
  * error arrives MiddlewareError-wrapped with raw provider prose and is
@@ -108,7 +117,7 @@ export async function runDeepAgentTurn(input: TurnInput, sink: TurnSink, config:
108
117
  * broke.
109
118
  */
110
119
  function classifyThrown(err: unknown, input: TurnInput, config: DeepAgentAdapterConfig, modelName: string | undefined): TurnOutcome {
111
- if (isGraphRecursionError(err)) {
120
+ if (isToolCallBudgetStop(err)) {
112
121
  console.log(`ExecuteDeepAgent reached the tool-call limit: execution=${input.executionId}`);
113
122
  return { kind: "tool_call_limit" };
114
123
  }
@@ -337,6 +337,8 @@ export function toProtoEvent(desc: WorkflowEventDescriptor): WorkflowExecutionEv
337
337
  : undefined,
338
338
  comment: desc.comment,
339
339
  waitDurationMs: BigInt(desc.waitDurationMs),
340
+ outcome: desc.outcome,
341
+ autoResolved: desc.autoResolved,
340
342
  }),
341
343
  };
342
344
  break;
package/src/config.ts CHANGED
@@ -113,6 +113,15 @@ export interface Config {
113
113
  * and shared/conversation-attachment.ts.
114
114
  */
115
115
  readonly mcpBridgeEndpoint: string | null;
116
+ /**
117
+ * The Stigmer server's public address (STIGMER_MCP_PUBLIC_ENDPOINT), which
118
+ * the server hands every sandbox it provisions or an operator names to a
119
+ * runner started by hand. When set, it fills a missing
120
+ * STIGMER_SERVER_ADDRESS for any MCP server that declares the key; null
121
+ * leaves a remote server's address unfilled. See
122
+ * shared/platform-server-address.ts.
123
+ */
124
+ readonly mcpPublicEndpoint: string | null;
116
125
  /**
117
126
  * The Cursor API key for direct mode (authenticates with Cursor directly).
118
127
  * In proxy mode this is a placeholder (`"proxy-managed"` unless the operator
@@ -232,6 +241,7 @@ export function loadConfig(): Config {
232
241
  const stigmerTokenRef: TokenRef = { current: stigmerTokenValue ?? null };
233
242
 
234
243
  const mcpBridgeEndpoint = process.env.STIGMER_MCP_BRIDGE_ENDPOINT ?? null;
244
+ const mcpPublicEndpoint = process.env.STIGMER_MCP_PUBLIC_ENDPOINT || null;
235
245
 
236
246
  // In proxy mode the SDK's API key is the control-plane credential, which the
237
247
  // Cursor adapter reads from the ref per turn (execute-cursor/turn-setup.ts);
@@ -285,6 +295,7 @@ export function loadConfig(): Config {
285
295
  stigmerBackendEndpoint,
286
296
  stigmerTokenRef,
287
297
  mcpBridgeEndpoint,
298
+ mcpPublicEndpoint,
288
299
  cursorApiKey,
289
300
  workspaceRootDir,
290
301
  mode,
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The exit backstop for static and pool modes: once the runner's own
3
+ * shutdown has finished (worker drained, harnesses released, Temporal
4
+ * connection closed, OTel flushed), the process must end within a named
5
+ * bound, and when it has to be MADE to end, the log names what kept it
6
+ * alive.
7
+ *
8
+ * Static and pool modes end when the Node event loop drains. That is the
9
+ * normal path and it stays the normal path: a healthy runner exits in
10
+ * milliseconds on its own. The failure it guards against is one leaked
11
+ * handle — a timer, a socket, a child — that outlives the worker and holds
12
+ * the process until the orchestrator's grace expires. One uncleared timer
13
+ * in our own code did exactly that to every pool-member roll for three
14
+ * months (issue #1008), and the next one may sit in a vendor SDK we do not
15
+ * own. A hosted sandbox pod's grace is ten minutes, so the cost of a leak
16
+ * is not bounded by anything the runner controls — until this module.
17
+ *
18
+ * Why these choices:
19
+ *
20
+ * - The timer is `unref()`'d, so arming it never keeps the process alive.
21
+ * A drained loop exits naturally and the timer dies with it; the
22
+ * backstop only ever fires when something else is still holding the
23
+ * loop. An unref'd timer is also absent from
24
+ * `process.getActiveResourcesInfo()`, so the line never names the
25
+ * backstop itself.
26
+ * - The bound sits above the conformance harness's slow-exit threshold
27
+ * (2 s, `test/conformance/src/harness/runner-process.ts`), so a leak is
28
+ * still reported there as a slow exit instead of being hidden under it,
29
+ * and far below the harness's 30 s and the hosted 10-minute graces.
30
+ * - A forced exit exits 0. By the time the backstop is armed the drain and
31
+ * the flush have completed; the lingering handle is a defect named in the
32
+ * log, not a failed shutdown, and a non-zero code would make Kubernetes
33
+ * and the desktop host report a crash for a clean stop.
34
+ * - It is armed only after everything the shutdown awaits. Work that
35
+ * nobody awaits (a fire-and-forget promise) is the only thing it can cut;
36
+ * if such work matters at exit, the fix is to await it, and this line
37
+ * names it the first time it happens.
38
+ *
39
+ * Manager mode does not use this: it is driven by its host over stdin and
40
+ * exits at once after its shutdown (issue #177, `main.ts`).
41
+ */
42
+
43
+ /** How long the process may outlive its own completed shutdown before it is made to exit. */
44
+ export const EXIT_BACKSTOP_MS = 5_000;
45
+
46
+ export interface ExitBackstopOptions {
47
+ /** The bound; defaults to {@link EXIT_BACKSTOP_MS}. */
48
+ readonly boundMs?: number;
49
+ /** Ends the process; defaults to `process.exit`. */
50
+ readonly exit?: (code: number) => void;
51
+ /** Names what still holds the loop; defaults to `process.getActiveResourcesInfo`. */
52
+ readonly activeResources?: () => string[];
53
+ /** Writes the forced-exit line; defaults to `console.warn`. */
54
+ readonly log?: (line: string) => void;
55
+ }
56
+
57
+ /**
58
+ * Arm the backstop. Call it once, after the mode's shutdown has returned.
59
+ * Returns the armed timer so a test can observe that it does not hold the
60
+ * loop; production callers ignore it.
61
+ */
62
+ export function armExitBackstop(
63
+ options: ExitBackstopOptions = {},
64
+ ): ReturnType<typeof setTimeout> {
65
+ const boundMs = options.boundMs ?? EXIT_BACKSTOP_MS;
66
+ const exit = options.exit ?? ((code: number) => process.exit(code));
67
+ const activeResources = options.activeResources ?? (() => process.getActiveResourcesInfo());
68
+ const log = options.log ?? ((line: string) => console.warn(line));
69
+
70
+ const timer = setTimeout(() => {
71
+ log(
72
+ `[runner] Process still alive ${boundMs}ms after shutdown; forcing exit. ` +
73
+ `Active resources: ${describeResources(activeResources())}`,
74
+ );
75
+ exit(0);
76
+ }, boundMs);
77
+ timer.unref();
78
+ return timer;
79
+ }
80
+
81
+ /**
82
+ * `["Timeout", "TCPSocketWrap", "TCPSocketWrap"]` → `"TCPSocketWrap×2, Timeout×1"`:
83
+ * grouped and sorted, so the same leak reads the same on every exit.
84
+ */
85
+ export function describeResources(resources: readonly string[]): string {
86
+ if (resources.length === 0) {
87
+ return "none reported";
88
+ }
89
+ const counts = new Map<string, number>();
90
+ for (const resource of resources) {
91
+ counts.set(resource, (counts.get(resource) ?? 0) + 1);
92
+ }
93
+ return [...counts.entries()]
94
+ .sort(([a], [b]) => a.localeCompare(b))
95
+ .map(([resource, count]) => `${resource}×${count}`)
96
+ .join(", ");
97
+ }