agent-tower 0.6.0-beta.13 → 0.6.0-beta.15

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 (142) hide show
  1. package/dist/executors/default-providers.json +1 -0
  2. package/dist/runtime/__tests__/acp-driver-lifecycle.test.js +232 -6
  3. package/dist/runtime/__tests__/acp-driver-lifecycle.test.js.map +1 -1
  4. package/dist/runtime/__tests__/acp-driver.test.js +24 -0
  5. package/dist/runtime/__tests__/acp-driver.test.js.map +1 -1
  6. package/dist/runtime/__tests__/runtime-coordinator.test.js +205 -7
  7. package/dist/runtime/__tests__/runtime-coordinator.test.js.map +1 -1
  8. package/dist/runtime/acp/acp-driver.d.ts.map +1 -1
  9. package/dist/runtime/acp/acp-driver.js +188 -61
  10. package/dist/runtime/acp/acp-driver.js.map +1 -1
  11. package/dist/runtime/acp/codex-provider-config.d.ts.map +1 -1
  12. package/dist/runtime/acp/codex-provider-config.js +4 -0
  13. package/dist/runtime/acp/codex-provider-config.js.map +1 -1
  14. package/dist/runtime/contracts.d.ts +12 -0
  15. package/dist/runtime/contracts.d.ts.map +1 -1
  16. package/dist/runtime/runtime-coordinator.d.ts +22 -1
  17. package/dist/runtime/runtime-coordinator.d.ts.map +1 -1
  18. package/dist/runtime/runtime-coordinator.js +214 -26
  19. package/dist/runtime/runtime-coordinator.js.map +1 -1
  20. package/dist/services/__tests__/session-manager.lifecycle.test.js +578 -68
  21. package/dist/services/__tests__/session-manager.lifecycle.test.js.map +1 -1
  22. package/dist/services/__tests__/session-manager.team-run.test.js +2 -1
  23. package/dist/services/__tests__/session-manager.team-run.test.js.map +1 -1
  24. package/dist/services/session-manager.d.ts +14 -8
  25. package/dist/services/session-manager.d.ts.map +1 -1
  26. package/dist/services/session-manager.js +359 -176
  27. package/dist/services/session-manager.js.map +1 -1
  28. package/dist/web/assets/{AgentDemoPage-aLGw8pYk.js → AgentDemoPage-04hKRdNk.js} +1 -1
  29. package/dist/web/assets/{AgentEnvironmentSettingsPage-BYcH_wMz.js → AgentEnvironmentSettingsPage-TnYB_gn_.js} +1 -1
  30. package/dist/web/assets/{AgentLogo-Dyqxcj5s.js → AgentLogo-BQ4AhPCM.js} +1 -1
  31. package/dist/web/assets/ConversationPage-GSAFcHuX.js +3 -0
  32. package/dist/web/assets/CreateTaskInput-DSl73Wwc.js +1 -0
  33. package/dist/web/assets/{DemoPage-m92AArTk.js → DemoPage-BKlcChAw.js} +1 -1
  34. package/dist/web/assets/{GeneralSettingsPage-P5HcwCXo.js → GeneralSettingsPage-sVFvK9Oe.js} +1 -1
  35. package/dist/web/assets/{Icons-BFOphLw-.js → Icons-DGahMuvC.js} +1 -1
  36. package/dist/web/assets/{LoadingPreviewPage-CfT4FIyb.js → LoadingPreviewPage-CZ2N3qZc.js} +1 -1
  37. package/dist/web/assets/{McpSettingsPage-BI_b78L_.js → McpSettingsPage-KZFzkxCs.js} +1 -1
  38. package/dist/web/assets/{MemberAvatar-1UlgMZMM.js → MemberAvatar-5bEbGww5.js} +1 -1
  39. package/dist/web/assets/{NotificationSettingsPage-CQCyq9QZ.js → NotificationSettingsPage-hVClKCOc.js} +1 -1
  40. package/dist/web/assets/{ProfileSettingsPage-ojhJXLWb.js → ProfileSettingsPage-BICdSvU4.js} +1 -1
  41. package/dist/web/assets/{ProjectKanbanPage-DK3rZJzY.js → ProjectKanbanPage-D62MacD4.js} +27 -27
  42. package/dist/web/assets/{ProjectSettingsPage-MGB631ia.js → ProjectSettingsPage-aMerK2A7.js} +1 -1
  43. package/dist/web/assets/{ProviderSettingsPage-CMZvEWMa.js → ProviderSettingsPage-Djd3iQfB.js} +1 -1
  44. package/dist/web/assets/{SettingsMasterDetail-9CnCBuNZ.js → SettingsMasterDetail-Czojx5vD.js} +1 -1
  45. package/dist/web/assets/{TeamSettingsPage-BUXKd5UF.js → TeamSettingsPage-B3-4MXXx.js} +1 -1
  46. package/dist/web/assets/{arc-2leUEhVa.js → arc-DzPiJSKL.js} +1 -1
  47. package/dist/web/assets/{architectureDiagram-3BPJPVTR-CDgYR44L.js → architectureDiagram-3BPJPVTR-EhKxHp4t.js} +1 -1
  48. package/dist/web/assets/{arrow-left-Jn-v17JF.js → arrow-left-2dTz1a0q.js} +1 -1
  49. package/dist/web/assets/{blockDiagram-GPEHLZMM-CzfIh31g.js → blockDiagram-GPEHLZMM-BHcWmA7M.js} +1 -1
  50. package/dist/web/assets/{c4Diagram-AAUBKEIU-BzzBXKEF.js → c4Diagram-AAUBKEIU-D59zNlIS.js} +1 -1
  51. package/dist/web/assets/channel-BpGJqMDu.js +1 -0
  52. package/dist/web/assets/check-CYQXIceH.js +1 -0
  53. package/dist/web/assets/{chevron-down-CpKODb7Y.js → chevron-down-BTGC8Sed.js} +1 -1
  54. package/dist/web/assets/{chevron-right-CBoUSXVq.js → chevron-right-BEw6jjbJ.js} +1 -1
  55. package/dist/web/assets/{chevron-up-KZ1hsL8y.js → chevron-up-D_Hmx6ug.js} +1 -1
  56. package/dist/web/assets/{chunk-2J33WTMH-GJE6LCGS.js → chunk-2J33WTMH-nk1Ieiq7.js} +1 -1
  57. package/dist/web/assets/{chunk-4BX2VUAB-Bw_ITp1Q.js → chunk-4BX2VUAB-xl3tQkpA.js} +1 -1
  58. package/dist/web/assets/{chunk-55IACEB6-DQ-XuWbD.js → chunk-55IACEB6-Bj12k-M-.js} +1 -1
  59. package/dist/web/assets/{chunk-727SXJPM-BgL49Dtw.js → chunk-727SXJPM-DeL4hVho.js} +1 -1
  60. package/dist/web/assets/{chunk-AQP2D5EJ-dRTVohoy.js → chunk-AQP2D5EJ-xyt_hZ7N.js} +1 -1
  61. package/dist/web/assets/{chunk-FMBD7UC4-BZtRjoH7.js → chunk-FMBD7UC4-DMpougEb.js} +1 -1
  62. package/dist/web/assets/{chunk-ND2GUHAM-CN3wND6y.js → chunk-ND2GUHAM-D2qjylxW.js} +1 -1
  63. package/dist/web/assets/{chunk-QZHKN3VN-CMwbobMj.js → chunk-QZHKN3VN-BjCtAePb.js} +1 -1
  64. package/dist/web/assets/{circle-alert-kELlE07n.js → circle-alert-BGbIBYEs.js} +1 -1
  65. package/dist/web/assets/classDiagram-4FO5ZUOK-M-OAZSzx.js +1 -0
  66. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-M-OAZSzx.js +1 -0
  67. package/dist/web/assets/{code-block-OCS4YCEC-B1YC7dLa.js → code-block-OCS4YCEC-DnfSnuHM.js} +1 -1
  68. package/dist/web/assets/{confirm-dialog-DDWlvtJs.js → confirm-dialog-C8rPqdFE.js} +1 -1
  69. package/dist/web/assets/{cose-bilkent-S5V4N54A-C6xD7kmY.js → cose-bilkent-S5V4N54A-DD3H-OxN.js} +1 -1
  70. package/dist/web/assets/{cssMode-lB5vNGvS.js → cssMode-DR6Jf3-g.js} +1 -1
  71. package/dist/web/assets/{dagre-BM42HDAG-BqTCYMO6.js → dagre-BM42HDAG-Bw6aZrG9.js} +1 -1
  72. package/dist/web/assets/{diagram-2AECGRRQ-BsMq3s5l.js → diagram-2AECGRRQ-Bm8oLz0N.js} +1 -1
  73. package/dist/web/assets/{diagram-5GNKFQAL-l4izpv7Z.js → diagram-5GNKFQAL-CL9Xaz3k.js} +1 -1
  74. package/dist/web/assets/{diagram-KO2AKTUF-uWDq6Yzo.js → diagram-KO2AKTUF-KbdYV5XJ.js} +1 -1
  75. package/dist/web/assets/{diagram-LMA3HP47-Dbd4fnFa.js → diagram-LMA3HP47-CYo7CoGp.js} +1 -1
  76. package/dist/web/assets/{diagram-OG6HWLK6-BC-27mkr.js → diagram-OG6HWLK6-CFPSh1Sm.js} +1 -1
  77. package/dist/web/assets/{erDiagram-TEJ5UH35-DrCP0Xt8.js → erDiagram-TEJ5UH35-Dh9qfvJz.js} +1 -1
  78. package/dist/web/assets/{flowDiagram-I6XJVG4X-BqNsQuUr.js → flowDiagram-I6XJVG4X-8EMyvj0V.js} +1 -1
  79. package/dist/web/assets/{folder-picker-CBVEWiuS.js → folder-picker-iHGLWmJF.js} +1 -1
  80. package/dist/web/assets/{freemarker2-Bos_2jdw.js → freemarker2-Dhyh-DXo.js} +1 -1
  81. package/dist/web/assets/{ganttDiagram-6RSMTGT7-CZOrG-5M.js → ganttDiagram-6RSMTGT7-BE5FBwwf.js} +1 -1
  82. package/dist/web/assets/{gitGraphDiagram-PVQCEYII-Dp3i5ehs.js → gitGraphDiagram-PVQCEYII-Dx2x_Y6A.js} +1 -1
  83. package/dist/web/assets/{handlebars-C7Elz3nH.js → handlebars-BB4WaAAq.js} +1 -1
  84. package/dist/web/assets/{html-EvsHOk5n.js → html-BluMUCI1.js} +1 -1
  85. package/dist/web/assets/{htmlMode-CTlhNZgJ.js → htmlMode-Dk0meNHs.js} +1 -1
  86. package/dist/web/assets/{index-hPp73HMS.js → index-9oo7US_J.js} +4 -4
  87. package/dist/web/assets/{index-BGajgi5H.js → index-B0gHfTIK.js} +3 -3
  88. package/dist/web/assets/{infoDiagram-5YYISTIA-C6NnR_Zb.js → infoDiagram-5YYISTIA-0BPLGHMf.js} +1 -1
  89. package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-C1SOf-Ko.js → ishikawaDiagram-YF4QCWOH-BnoV5etH.js} +1 -1
  90. package/dist/web/assets/javascript-Cwz9LBET.js +1 -0
  91. package/dist/web/assets/{journeyDiagram-JHISSGLW--GKMSbPz.js → journeyDiagram-JHISSGLW-D8_dQMoV.js} +1 -1
  92. package/dist/web/assets/{jsonMode-Bbh3Gbb8.js → jsonMode-DWAIY3Zm.js} +1 -1
  93. package/dist/web/assets/{kanban-definition-UN3LZRKU-Car5iy0R.js → kanban-definition-UN3LZRKU-CcP-UBWq.js} +1 -1
  94. package/dist/web/assets/{layers-WOwm63Fo.js → layers-B4Hb2k1E.js} +1 -1
  95. package/dist/web/assets/{linear-ChUj9SvT.js → linear-CFeIj_93.js} +1 -1
  96. package/dist/web/assets/{liquid-BXFWITFq.js → liquid-DszhNRew.js} +1 -1
  97. package/dist/web/assets/{lspLanguageFeatures-DQ2CrOBI.js → lspLanguageFeatures-DyajWL04.js} +1 -1
  98. package/dist/web/assets/{mdx-vHLpslzI.js → mdx-D3Dg3IU3.js} +1 -1
  99. package/dist/web/assets/{mermaid-NOHMQCX5-CzwPLFrI.js → mermaid-NOHMQCX5-De7jApSL.js} +4 -4
  100. package/dist/web/assets/{message-square-DgdFa1Kc.js → message-square-Dly_bein.js} +1 -1
  101. package/dist/web/assets/{mindmap-definition-RKZ34NQL-CfzbnHY_.js → mindmap-definition-RKZ34NQL-BBl0Q2SW.js} +1 -1
  102. package/dist/web/assets/{modal-QnCJhV9P.js → modal-CyT9BpRm.js} +1 -1
  103. package/dist/web/assets/{monaco-CbFQByO7.js → monaco-DBlH7Gvr.js} +3 -3
  104. package/dist/web/assets/{pencil-CNVU3zfM.js → pencil-BLlfV3fH.js} +1 -1
  105. package/dist/web/assets/{pieDiagram-4H26LBE5-CDqYJneU.js → pieDiagram-4H26LBE5-BVrsUXUt.js} +1 -1
  106. package/dist/web/assets/{python-BhFSY_J2.js → python-C1GF9JX6.js} +1 -1
  107. package/dist/web/assets/{quadrantDiagram-W4KKPZXB-B6ChDIx2.js → quadrantDiagram-W4KKPZXB-Brnd7Z-x.js} +1 -1
  108. package/dist/web/assets/{razor-Cx_DrE-V.js → razor-CNwR1Ppb.js} +1 -1
  109. package/dist/web/assets/{requirementDiagram-4Y6WPE33-DefLT9NZ.js → requirementDiagram-4Y6WPE33-BEL5JUv5.js} +1 -1
  110. package/dist/web/assets/{sankeyDiagram-5OEKKPKP-CQHnovPz.js → sankeyDiagram-5OEKKPKP-Dg3pq8MF.js} +1 -1
  111. package/dist/web/assets/{select-CmbeAAVh.js → select-BQuOHpgx.js} +1 -1
  112. package/dist/web/assets/{sequenceDiagram-3UESZ5HK-DZuaKm7C.js → sequenceDiagram-3UESZ5HK-DvXxqwal.js} +1 -1
  113. package/dist/web/assets/{server-BUivqMYo.js → server-DmNZ4TcQ.js} +1 -1
  114. package/dist/web/assets/{stateDiagram-AJRCARHV-DvxpChEC.js → stateDiagram-AJRCARHV-BSXeM4wR.js} +1 -1
  115. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-Cjr-lS2a.js +1 -0
  116. package/dist/web/assets/{switch-CiOyM13e.js → switch-BDzVWx67.js} +1 -1
  117. package/dist/web/assets/{textarea-D4HkYa3u.js → textarea-D4CrProF.js} +1 -1
  118. package/dist/web/assets/{timeline-definition-PNZ67QCA-BRu-tS-p.js → timeline-definition-PNZ67QCA-3rH0QWp7.js} +1 -1
  119. package/dist/web/assets/{trash-2-DPxBnxJM.js → trash-2-DHu1aC-u.js} +1 -1
  120. package/dist/web/assets/{tsMode-CHcZwUmC.js → tsMode-Z5KzKNgl.js} +1 -1
  121. package/dist/web/assets/{typescript-CvtjMxoq.js → typescript-Cg8IFK7o.js} +1 -1
  122. package/dist/web/assets/{upload-YmLtnZsU.js → upload-DQPwfdPh.js} +1 -1
  123. package/dist/web/assets/{use-profiles-OJAwfc90.js → use-profiles-DI5-xqHU.js} +1 -1
  124. package/dist/web/assets/{use-projects-iob5C7Xk.js → use-projects-Cs0-FMed.js} +1 -1
  125. package/dist/web/assets/{use-providers-Cj9S9_2q.js → use-providers-BoVFTPYO.js} +1 -1
  126. package/dist/web/assets/{useNormalizedLogs-BhN4AN4f.js → useNormalizedLogs-DhoOmBJo.js} +1 -1
  127. package/dist/web/assets/{vennDiagram-CIIHVFJN-CtZ_3Ld0.js → vennDiagram-CIIHVFJN-DAjUhsg9.js} +1 -1
  128. package/dist/web/assets/{wardley-L42UT6IY-h9yfTmIn.js → wardley-L42UT6IY-Db8PSpCZ.js} +1 -1
  129. package/dist/web/assets/{wardleyDiagram-YWT4CUSO-CWXtZtix.js → wardleyDiagram-YWT4CUSO-DfXfIOXM.js} +1 -1
  130. package/dist/web/assets/{xml-DhyGRwiG.js → xml-UOLWFxtD.js} +1 -1
  131. package/dist/web/assets/{xychartDiagram-2RQKCTM6-BLuWVWlL.js → xychartDiagram-2RQKCTM6-C41mtVgh.js} +1 -1
  132. package/dist/web/assets/{yaml-Cpi6SvoR.js → yaml-CeY5SySy.js} +1 -1
  133. package/dist/web/index.html +1 -1
  134. package/package.json +2 -2
  135. package/dist/web/assets/ConversationPage-BBnK1qA_.js +0 -3
  136. package/dist/web/assets/CreateTaskInput-B11wGhDW.js +0 -1
  137. package/dist/web/assets/channel-XuYe6lH7.js +0 -1
  138. package/dist/web/assets/check-FwR4dMNY.js +0 -1
  139. package/dist/web/assets/classDiagram-4FO5ZUOK-DWSNJHiT.js +0 -1
  140. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-DWSNJHiT.js +0 -1
  141. package/dist/web/assets/javascript-Desq1P0G.js +0 -1
  142. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-BbrRYMxK.js +0 -1
@@ -151,6 +151,121 @@ function waitForEvent(eventBus, event, timeoutMs = 5000) {
151
151
  });
152
152
  });
153
153
  }
154
+ async function createRetryableAcpStopFixture(closeFailureMessages) {
155
+ const provider = {
156
+ id: 'cleanup-retry-acp-provider',
157
+ name: 'Cleanup Retry ACP Provider',
158
+ agentType: AgentType.CODEX,
159
+ runtimeType: RuntimeType.ACP,
160
+ env: {},
161
+ config: {},
162
+ isDefault: false,
163
+ };
164
+ getProviderByIdMock.mockReturnValue(provider);
165
+ const { workspace, session } = await createSessionFixture({ providerId: provider.id });
166
+ await prisma.session.update({
167
+ where: { id: session.id },
168
+ data: { runtimeType: RuntimeType.ACP },
169
+ });
170
+ const service = new WorkspaceBackgroundService();
171
+ const turns = [];
172
+ const observedCredentials = [];
173
+ const driverSessions = [];
174
+ const remainingCloseFailures = [...closeFailureMessages];
175
+ const capabilities = {
176
+ loadSession: true,
177
+ terminalInput: false,
178
+ terminalResize: false,
179
+ permissions: true,
180
+ };
181
+ const driver = {
182
+ type: RuntimeType.ACP,
183
+ open: vi.fn(async (input, openingSink) => {
184
+ const generation = driverSessions.length + 1;
185
+ const generationTurns = [];
186
+ const driverSession = {
187
+ runtimeInstanceId: `cleanup-retry-runtime-${generation}`,
188
+ capabilities,
189
+ externalSessionId: 'cleanup-retry-external',
190
+ runTurn: vi.fn(async (_turn, sink) => {
191
+ const credential = input.env.get(AGENT_API_CREDENTIAL_ENV) ?? '';
192
+ observedCredentials.push(credential);
193
+ const identity = validateAgentApiCredential(credential);
194
+ if (!identity)
195
+ throw new Error('ACP workspace-service credential is invalid');
196
+ await service.authorizeCaller(workspace.id, { kind: 'agent', ...identity });
197
+ sink.stream({
198
+ type: 'external_session_id',
199
+ externalSessionId: 'cleanup-retry-external',
200
+ });
201
+ const completion = deferred();
202
+ turns.push(completion);
203
+ generationTurns.push(completion);
204
+ return { completion: completion.promise };
205
+ }),
206
+ cancelTurn: vi.fn(async () => {
207
+ generationTurns.at(-1)?.resolve({ stopReason: 'cancelled' });
208
+ }),
209
+ close: vi.fn(async () => {
210
+ generationTurns.at(-1)?.resolve({ stopReason: 'cancelled' });
211
+ if (generation === 1) {
212
+ const failure = remainingCloseFailures.shift();
213
+ if (failure)
214
+ throw new Error(failure);
215
+ }
216
+ }),
217
+ };
218
+ await openingSink.process({
219
+ type: 'started',
220
+ runtimeInstanceId: driverSession.runtimeInstanceId,
221
+ launchClaimNumber: input.launchClaimNumber,
222
+ pid: 4400 + generation,
223
+ processGroupId: String(4400 + generation),
224
+ birthMarker: `test-birth:${4400 + generation}`,
225
+ ownershipToken: `test-owner:${4400 + generation}`,
226
+ });
227
+ driverSessions.push(driverSession);
228
+ return driverSession;
229
+ }),
230
+ };
231
+ const eventBus = new EventBus();
232
+ const manager = new SessionManager(eventBus, undefined, new StaticRuntimeRegistry([driver]));
233
+ const completed = waitForEvent(eventBus, 'session:completed');
234
+ await manager.start(session.id);
235
+ turns[0].resolve({ stopReason: 'end_turn' });
236
+ await completed;
237
+ await vi.waitFor(() => expect(sessionMsgStoreManager.has(session.id)).toBe(false));
238
+ await manager.sendMessage(session.id, 'use workspace service again');
239
+ return {
240
+ driver,
241
+ driverSessions,
242
+ manager,
243
+ observedCredentials,
244
+ session,
245
+ turns,
246
+ };
247
+ }
248
+ async function getPersistedCleanupInput(sessionId, runtimeInstanceId) {
249
+ const process = await prisma.executionProcess.findFirstOrThrow({
250
+ where: { sessionId, runtimeInstanceId },
251
+ });
252
+ if (process.launchClaimNumber == null
253
+ || process.pid == null
254
+ || process.processGroupId == null
255
+ || process.birthMarker == null
256
+ || process.ownershipToken == null) {
257
+ throw new Error('Test runtime process identity is incomplete');
258
+ }
259
+ return {
260
+ sessionId,
261
+ runtimeInstanceId,
262
+ launchClaimNumber: process.launchClaimNumber,
263
+ pid: process.pid,
264
+ processGroupId: process.processGroupId,
265
+ birthMarker: process.birthMarker,
266
+ ownershipToken: process.ownershipToken,
267
+ };
268
+ }
154
269
  describe('SessionManager session status vs real process state', () => {
155
270
  beforeAll(async () => {
156
271
  execFileSync('pnpm', ['exec', 'prisma', 'db', 'push', '--skip-generate', `--schema=${schemaPath}`], {
@@ -310,7 +425,7 @@ describe('SessionManager session status vs real process state', () => {
310
425
  .resolves.toMatchObject({ status: SessionStatus.RUNNING });
311
426
  await manager.destroyAll();
312
427
  });
313
- it('marks a reused ACP follow-up RUNNING without a new process event and allows it to stop', async () => {
428
+ it('serializes ACP stop and resend, then reconnects with the persisted external session id', async () => {
314
429
  const provider = {
315
430
  id: 'reused-acp-provider',
316
431
  name: 'Reused ACP Provider',
@@ -356,11 +471,26 @@ describe('SessionManager session status vs real process state', () => {
356
471
  .mockReturnValueOnce(false)
357
472
  .mockReturnValueOnce(true)
358
473
  .mockReturnValue(false);
474
+ const stopCleanup = deferred();
475
+ const withStartAdmission = async (towerSessionId, operation) => operation({
476
+ towerSessionId,
477
+ signal: new AbortController().signal,
478
+ throwIfCancelled: () => undefined,
479
+ });
359
480
  const runtimeCoordinator = {
360
481
  hasActiveTurn,
482
+ withStartAdmission: vi.fn(withStartAdmission),
483
+ retryDisposedSessionCleanup: vi.fn(async () => undefined),
361
484
  startTurn: vi.fn(async (_input) => ({ turnId: 'turn-2', completion })),
362
485
  abandonTurn: vi.fn(async () => true),
363
486
  cancelTurn: vi.fn(async () => undefined),
487
+ cancelAndDisposeSession: vi.fn(async () => {
488
+ await stopCleanup.promise;
489
+ await prisma.executionProcess.updateMany({
490
+ where: { sessionId: session.id },
491
+ data: { cleanupState: 'CONFIRMED' },
492
+ });
493
+ }),
364
494
  disposeSession: vi.fn(async () => {
365
495
  await prisma.executionProcess.updateMany({
366
496
  where: { sessionId: session.id },
@@ -398,12 +528,29 @@ describe('SessionManager session status vs real process state', () => {
398
528
  where: { id: session.id },
399
529
  data: { status: SessionStatus.COMPLETED },
400
530
  });
401
- await manager.stop(session.id);
402
- expect(runtimeCoordinator.abandonTurn).toHaveBeenCalledWith(session.id);
531
+ const stopping = manager.stop(session.id);
532
+ await vi.waitFor(() => {
533
+ expect(runtimeCoordinator.cancelAndDisposeSession).toHaveBeenCalledWith(session.id);
534
+ });
535
+ const resending = manager.sendMessage(session.id, 'resend after explicit stop');
536
+ await Promise.resolve();
537
+ expect(runtimeCoordinator.startTurn).toHaveBeenCalledTimes(1);
538
+ stopCleanup.resolve();
539
+ await stopping;
540
+ await resending;
541
+ expect(runtimeCoordinator.abandonTurn).not.toHaveBeenCalled();
403
542
  expect(runtimeCoordinator.cancelTurn).not.toHaveBeenCalled();
404
- expect(runtimeCoordinator.disposeSession).toHaveBeenCalledWith(session.id);
543
+ expect(runtimeCoordinator.disposeSession).not.toHaveBeenCalled();
544
+ expect(runtimeCoordinator.startTurn).toHaveBeenCalledTimes(2);
545
+ expect(runtimeCoordinator.startTurn.mock.calls[1]?.[0]).toEqual(expect.objectContaining({
546
+ towerSessionId: session.id,
547
+ externalSessionId: 'external-acp-session',
548
+ resumeExternalSessionId: 'external-acp-session',
549
+ resumeMode: 'resume',
550
+ prompt: expect.stringContaining('resend after explicit stop'),
551
+ }));
405
552
  expect((await prisma.session.findUnique({ where: { id: session.id } }))?.status)
406
- .toBe(SessionStatus.CANCELLED);
553
+ .toBe(SessionStatus.RUNNING);
407
554
  await manager.destroyAll();
408
555
  });
409
556
  it('loads ACP history for an incomplete session even when a snapshot exists', async () => {
@@ -432,8 +579,15 @@ describe('SessionManager session status vs real process state', () => {
432
579
  },
433
580
  });
434
581
  const completion = new Promise(() => undefined);
582
+ const withStartAdmission = async (towerSessionId, operation) => operation({
583
+ towerSessionId,
584
+ signal: new AbortController().signal,
585
+ throwIfCancelled: () => undefined,
586
+ });
435
587
  const runtimeCoordinator = {
436
588
  hasActiveTurn: vi.fn(() => false),
589
+ withStartAdmission: vi.fn(withStartAdmission),
590
+ retryDisposedSessionCleanup: vi.fn(async () => undefined),
437
591
  startTurn: vi.fn(async (_input) => ({ turnId: 'turn-2', completion })),
438
592
  abandonTurn: vi.fn(async () => true),
439
593
  cancelTurn: vi.fn(async () => undefined),
@@ -455,75 +609,431 @@ describe('SessionManager session status vs real process state', () => {
455
609
  .toBe(startInput?.historyBoundaryEntryId);
456
610
  await manager.destroyAll();
457
611
  });
458
- it('keeps an ACP DriverSession credential valid across completed follow-up and revokes it on stop', async () => {
459
- const provider = {
460
- id: 'credential-acp-provider',
461
- name: 'Credential ACP Provider',
462
- agentType: AgentType.CODEX,
463
- runtimeType: RuntimeType.ACP,
464
- env: {},
465
- config: {},
466
- isDefault: false,
467
- };
468
- getProviderByIdMock.mockReturnValue(provider);
469
- const { workspace, session } = await createSessionFixture({ providerId: provider.id });
470
- await prisma.session.update({
612
+ it('retries a retained DISPOSED owner before an immediate ACP resend starts a new generation', async () => {
613
+ const { driver, driverSessions, manager, observedCredentials, session, } = await createRetryableAcpStopFixture(['owned tree still alive']);
614
+ const firstDriverSession = driverSessions[0];
615
+ expect(observedCredentials).toHaveLength(2);
616
+ expect(observedCredentials[1]).toBe(observedCredentials[0]);
617
+ await expect(manager.stop(session.id)).rejects.toThrow('owned tree still alive');
618
+ expect(firstDriverSession.close).toHaveBeenCalledOnce();
619
+ expect(validateAgentApiCredential(observedCredentials[1])).toBeNull();
620
+ await expect(manager.sendMessage(session.id, 'resend immediately after failed stop'))
621
+ .resolves.toMatchObject({ id: session.id });
622
+ expect(firstDriverSession.close).toHaveBeenCalledTimes(2);
623
+ expect(driver.open).toHaveBeenCalledTimes(2);
624
+ expect(vi.mocked(driver.open).mock.calls[1]?.[0]).toEqual(expect.objectContaining({
625
+ externalSessionId: 'cleanup-retry-external',
626
+ }));
627
+ expect(observedCredentials).toHaveLength(3);
628
+ expect(observedCredentials[2]).not.toBe(observedCredentials[1]);
629
+ expect(validateAgentApiCredential(observedCredentials[2])).toMatchObject({ sessionId: session.id });
630
+ expect(manager.hasRuntimeProcessOwner('cleanup-retry-runtime-1', session.id, 1)).toBe(false);
631
+ await expect(prisma.executionProcess.findFirstOrThrow({
632
+ where: { sessionId: session.id, runtimeInstanceId: 'cleanup-retry-runtime-1' },
633
+ })).resolves.toMatchObject({
634
+ launchClaimNumber: 1,
635
+ cleanupState: 'CONFIRMED',
636
+ cleanupError: null,
637
+ cleanupAttemptCount: 1,
638
+ });
639
+ await expect(prisma.session.findUnique({ where: { id: session.id } })).resolves.toMatchObject({
640
+ status: SessionStatus.RUNNING,
641
+ runtimeLaunchState: 'PROCESS_RECORDED',
642
+ runtimeLaunchClaimCount: 3,
643
+ runtimeLaunchResolvedCount: 3,
644
+ runtimeLaunchProcessCount: 2,
645
+ runtimeLaunchDiagnostic: null,
646
+ });
647
+ expect(await prisma.executionProcess.count({
648
+ where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
649
+ })).toBe(0);
650
+ await manager.destroyAll();
651
+ });
652
+ it('rejects an immediate ACP resend without advancing launch state when retained cleanup fails again', async () => {
653
+ const { driver, driverSessions, manager, observedCredentials, session, } = await createRetryableAcpStopFixture([
654
+ 'owned tree still alive',
655
+ 'owned tree still alive on resend',
656
+ ]);
657
+ const firstDriverSession = driverSessions[0];
658
+ await expect(manager.stop(session.id)).rejects.toThrow('owned tree still alive');
659
+ const launchStateBeforeResend = await prisma.session.findUniqueOrThrow({
471
660
  where: { id: session.id },
472
- data: { runtimeType: RuntimeType.ACP },
661
+ select: {
662
+ status: true,
663
+ runtimeLaunchState: true,
664
+ runtimeLaunchClaimCount: true,
665
+ runtimeLaunchResolvedCount: true,
666
+ runtimeLaunchProcessCount: true,
667
+ runtimeLaunchDiagnostic: true,
668
+ },
473
669
  });
474
- const service = new WorkspaceBackgroundService();
475
- const turns = [];
476
- const observedCredentials = [];
477
- let openInput;
478
- const capabilities = {
479
- loadSession: true,
480
- terminalInput: false,
481
- terminalResize: false,
482
- permissions: true,
483
- };
484
- const driverSession = {
485
- runtimeInstanceId: 'credential-acp-runtime',
486
- capabilities,
487
- externalSessionId: 'credential-acp-external',
488
- runTurn: vi.fn(async (_turn) => {
489
- const credential = openInput?.env.get(AGENT_API_CREDENTIAL_ENV) ?? '';
490
- observedCredentials.push(credential);
491
- const identity = validateAgentApiCredential(credential);
492
- if (!identity)
493
- throw new Error('ACP workspace-service credential is invalid');
494
- await service.authorizeCaller(workspace.id, { kind: 'agent', ...identity });
495
- const completion = deferred();
496
- turns.push(completion);
497
- return { completion: completion.promise };
498
- }),
499
- cancelTurn: vi.fn(async () => {
500
- turns.at(-1)?.resolve({ stopReason: 'cancelled' });
501
- }),
502
- close: vi.fn(async () => undefined),
503
- };
504
- const driver = {
505
- type: RuntimeType.ACP,
506
- open: vi.fn(async (input) => {
507
- openInput = input;
508
- return driverSession;
509
- }),
510
- };
511
- const eventBus = new EventBus();
512
- const manager = new SessionManager(eventBus, undefined, new StaticRuntimeRegistry([driver]));
513
- const completed = waitForEvent(eventBus, 'session:completed');
514
- await manager.start(session.id);
515
- turns[0].resolve({ stopReason: 'end_turn' });
516
- await completed;
517
- await vi.waitFor(() => expect(sessionMsgStoreManager.has(session.id)).toBe(false));
518
- await manager.sendMessage(session.id, 'use workspace service again');
670
+ expect(manager.terminalSessions.get(session.id)).toBe(SessionStatus.CANCELLED);
671
+ await expect(manager.sendMessage(session.id, 'resend while cleanup still fails'))
672
+ .rejects.toThrow('owned tree still alive on resend');
673
+ expect(firstDriverSession.close).toHaveBeenCalledTimes(2);
674
+ expect(driver.open).toHaveBeenCalledOnce();
519
675
  expect(observedCredentials).toHaveLength(2);
520
- expect(observedCredentials[1]).toBe(observedCredentials[0]);
521
- expect(validateAgentApiCredential(observedCredentials[1])).toMatchObject({ sessionId: session.id });
522
- await manager.stop(session.id);
523
- expect(driverSession.close).toHaveBeenCalledOnce();
524
676
  expect(validateAgentApiCredential(observedCredentials[1])).toBeNull();
677
+ expect(manager.terminalSessions.get(session.id)).toBe(SessionStatus.CANCELLED);
678
+ await expect(prisma.session.findUniqueOrThrow({
679
+ where: { id: session.id },
680
+ select: {
681
+ status: true,
682
+ runtimeLaunchState: true,
683
+ runtimeLaunchClaimCount: true,
684
+ runtimeLaunchResolvedCount: true,
685
+ runtimeLaunchProcessCount: true,
686
+ runtimeLaunchDiagnostic: true,
687
+ },
688
+ })).resolves.toEqual(launchStateBeforeResend);
689
+ expect(manager.hasRuntimeProcessOwner('cleanup-retry-runtime-1', session.id, 1)).toBe(true);
690
+ await expect(prisma.executionProcess.findFirstOrThrow({
691
+ where: { sessionId: session.id, runtimeInstanceId: 'cleanup-retry-runtime-1' },
692
+ })).resolves.toMatchObject({
693
+ launchClaimNumber: 1,
694
+ pid: 4401,
695
+ processGroupId: '4401',
696
+ birthMarker: 'test-birth:4401',
697
+ ownershipToken: 'test-owner:4401',
698
+ cleanupState: 'FAILED',
699
+ cleanupError: 'owned tree still alive on resend',
700
+ cleanupAttemptCount: 2,
701
+ });
702
+ expect(await prisma.executionProcess.count({
703
+ where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
704
+ })).toBe(0);
705
+ await expect(manager.stop(session.id)).resolves.toMatchObject({ id: session.id });
706
+ expect(firstDriverSession.close).toHaveBeenCalledTimes(3);
707
+ await expect(manager.sendMessage(session.id, 'send after cleanup recovery'))
708
+ .resolves.toMatchObject({ id: session.id });
709
+ expect(driver.open).toHaveBeenCalledTimes(2);
710
+ expect(observedCredentials).toHaveLength(3);
711
+ expect(validateAgentApiCredential(observedCredentials[2])).toMatchObject({ sessionId: session.id });
712
+ await expect(prisma.session.findUnique({ where: { id: session.id } })).resolves.toMatchObject({
713
+ status: SessionStatus.RUNNING,
714
+ runtimeLaunchState: 'PROCESS_RECORDED',
715
+ runtimeLaunchClaimCount: 3,
716
+ runtimeLaunchResolvedCount: 3,
717
+ runtimeLaunchProcessCount: 2,
718
+ runtimeLaunchDiagnostic: null,
719
+ });
720
+ expect(await prisma.executionProcess.count({
721
+ where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
722
+ })).toBe(0);
723
+ await manager.destroyAll();
724
+ });
725
+ it('holds background cleanup retry and resend behind the same disposal evidence boundary', async () => {
726
+ const { driver, driverSessions, manager, observedCredentials, session, } = await createRetryableAcpStopFixture([]);
727
+ const firstDriverSession = driverSessions[0];
728
+ const evidenceStarted = deferred();
729
+ const evidenceRelease = deferred();
730
+ const originalMarkCleanup = manager.markRuntimeProcessCleanupState.bind(manager);
731
+ const markCleanupSpy = vi.spyOn(manager, 'markRuntimeProcessCleanupState')
732
+ .mockImplementation(async (...args) => {
733
+ if (args[1] === 'cleanup-retry-runtime-1' && args[2] === 'CONFIRMED') {
734
+ evidenceStarted.resolve();
735
+ await evidenceRelease.promise;
736
+ }
737
+ return originalMarkCleanup(...args);
738
+ });
739
+ const coordinator = manager.runtimeCoordinator;
740
+ const retryDisposedSpy = vi.spyOn(coordinator, 'retryDisposedSessionCleanup');
741
+ const disposal = manager.disposeRuntimeSession(session.id, 'cleanup-retry-runtime-1');
742
+ await evidenceStarted.promise;
743
+ const cleanupInput = await getPersistedCleanupInput(session.id, 'cleanup-retry-runtime-1');
744
+ const launchStateBeforeAdmission = await prisma.session.findUniqueOrThrow({
745
+ where: { id: session.id },
746
+ select: {
747
+ status: true,
748
+ runtimeLaunchState: true,
749
+ runtimeLaunchClaimCount: true,
750
+ runtimeLaunchResolvedCount: true,
751
+ runtimeLaunchProcessCount: true,
752
+ runtimeLaunchDiagnostic: true,
753
+ },
754
+ });
755
+ const backgroundCleanup = manager.retryRuntimeProcessCleanup(cleanupInput);
756
+ const resend = manager.sendMessage(session.id, 'resend during cleanup evidence persistence');
757
+ await vi.waitFor(() => expect(retryDisposedSpy).toHaveBeenCalledTimes(2));
758
+ expect(firstDriverSession.close).toHaveBeenCalledOnce();
759
+ expect(driver.open).toHaveBeenCalledOnce();
760
+ expect(observedCredentials).toHaveLength(2);
761
+ await expect(prisma.session.findUniqueOrThrow({
762
+ where: { id: session.id },
763
+ select: {
764
+ status: true,
765
+ runtimeLaunchState: true,
766
+ runtimeLaunchClaimCount: true,
767
+ runtimeLaunchResolvedCount: true,
768
+ runtimeLaunchProcessCount: true,
769
+ runtimeLaunchDiagnostic: true,
770
+ },
771
+ })).resolves.toEqual(launchStateBeforeAdmission);
772
+ expect(await prisma.executionProcess.count({ where: { sessionId: session.id } })).toBe(1);
773
+ expect(await prisma.executionProcess.count({
774
+ where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
775
+ })).toBe(0);
776
+ evidenceRelease.resolve();
777
+ await Promise.all([disposal, backgroundCleanup, resend]);
778
+ expect(firstDriverSession.close).toHaveBeenCalledOnce();
779
+ expect(driver.open).toHaveBeenCalledTimes(2);
780
+ expect(observedCredentials).toHaveLength(3);
781
+ await expect(prisma.executionProcess.findFirstOrThrow({
782
+ where: { sessionId: session.id, runtimeInstanceId: 'cleanup-retry-runtime-1' },
783
+ })).resolves.toMatchObject({ cleanupState: 'CONFIRMED' });
784
+ expect(await prisma.executionProcess.count({
785
+ where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
786
+ })).toBe(0);
787
+ retryDisposedSpy.mockRestore();
788
+ markCleanupSpy.mockRestore();
789
+ await manager.destroyAll();
790
+ });
791
+ it('rejects concurrent cleanup and resend without a phantom claim when disposal evidence remains unconfirmed', async () => {
792
+ const { driver, driverSessions, manager, observedCredentials, session, } = await createRetryableAcpStopFixture([]);
793
+ const firstDriverSession = driverSessions[0];
794
+ const evidenceStarted = deferred();
795
+ const evidenceRelease = deferred();
796
+ let skipEvidencePersistence = true;
797
+ const originalMarkCleanup = manager.markRuntimeProcessCleanupState.bind(manager);
798
+ const markCleanupSpy = vi.spyOn(manager, 'markRuntimeProcessCleanupState')
799
+ .mockImplementation(async (...args) => {
800
+ if (args[1] === 'cleanup-retry-runtime-1'
801
+ && args[2] === 'CONFIRMED'
802
+ && skipEvidencePersistence) {
803
+ evidenceStarted.resolve();
804
+ await evidenceRelease.promise;
805
+ skipEvidencePersistence = false;
806
+ return undefined;
807
+ }
808
+ return originalMarkCleanup(...args);
809
+ });
810
+ const coordinator = manager.runtimeCoordinator;
811
+ const retryDisposedSpy = vi.spyOn(coordinator, 'retryDisposedSessionCleanup');
812
+ const disposal = manager.disposeRuntimeSession(session.id, 'cleanup-retry-runtime-1');
813
+ await evidenceStarted.promise;
814
+ const cleanupInput = await getPersistedCleanupInput(session.id, 'cleanup-retry-runtime-1');
815
+ const launchStateBeforeAdmission = await prisma.session.findUniqueOrThrow({
816
+ where: { id: session.id },
817
+ select: {
818
+ status: true,
819
+ runtimeLaunchState: true,
820
+ runtimeLaunchClaimCount: true,
821
+ runtimeLaunchResolvedCount: true,
822
+ runtimeLaunchProcessCount: true,
823
+ runtimeLaunchDiagnostic: true,
824
+ },
825
+ });
826
+ const backgroundCleanup = manager.retryRuntimeProcessCleanup(cleanupInput);
827
+ const resend = manager.sendMessage(session.id, 'resend while evidence persistence fails');
828
+ await vi.waitFor(() => expect(retryDisposedSpy).toHaveBeenCalledTimes(2));
829
+ const disposalResult = expect(disposal).rejects.toThrow('is not durably confirmed');
830
+ const backgroundResult = expect(backgroundCleanup).rejects.toMatchObject({
831
+ code: 'runtime_cleanup_pending',
832
+ retryable: true,
833
+ });
834
+ const resendResult = expect(resend).rejects.toMatchObject({
835
+ code: 'runtime_cleanup_pending',
836
+ retryable: true,
837
+ });
838
+ evidenceRelease.resolve();
839
+ await Promise.all([disposalResult, backgroundResult, resendResult]);
840
+ expect(firstDriverSession.close).toHaveBeenCalledOnce();
841
+ expect(driver.open).toHaveBeenCalledOnce();
842
+ expect(observedCredentials).toHaveLength(2);
843
+ await expect(prisma.session.findUniqueOrThrow({
844
+ where: { id: session.id },
845
+ select: {
846
+ status: true,
847
+ runtimeLaunchState: true,
848
+ runtimeLaunchClaimCount: true,
849
+ runtimeLaunchResolvedCount: true,
850
+ runtimeLaunchProcessCount: true,
851
+ runtimeLaunchDiagnostic: true,
852
+ },
853
+ })).resolves.toEqual(launchStateBeforeAdmission);
854
+ expect(manager.hasRuntimeProcessOwner('cleanup-retry-runtime-1', session.id, 1)).toBe(true);
855
+ await expect(prisma.executionProcess.findFirstOrThrow({
856
+ where: { sessionId: session.id, runtimeInstanceId: 'cleanup-retry-runtime-1' },
857
+ })).resolves.toMatchObject({ cleanupState: 'FAILED' });
858
+ expect(await prisma.executionProcess.count({
859
+ where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
860
+ })).toBe(0);
861
+ await expect(manager.retryRuntimeProcessCleanup(cleanupInput)).resolves.toBeUndefined();
862
+ expect(firstDriverSession.close).toHaveBeenCalledOnce();
863
+ expect(manager.hasRuntimeProcessOwner('cleanup-retry-runtime-1', session.id, 1)).toBe(false);
864
+ await expect(manager.sendMessage(session.id, 'resend after evidence recovery'))
865
+ .resolves.toMatchObject({ id: session.id });
866
+ expect(driver.open).toHaveBeenCalledTimes(2);
867
+ expect(observedCredentials).toHaveLength(3);
868
+ expect(await prisma.executionProcess.count({
869
+ where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
870
+ })).toBe(0);
871
+ retryDisposedSpy.mockRestore();
872
+ markCleanupSpy.mockRestore();
873
+ await manager.destroyAll();
874
+ });
875
+ it('cancels an admission-first resend before background cleanup can close its DriverSession', async () => {
876
+ const { driver, driverSessions, manager, observedCredentials, session, turns, } = await createRetryableAcpStopFixture([]);
877
+ const driverSession = driverSessions[0];
878
+ turns.at(-1).resolve({ stopReason: 'end_turn' });
879
+ await vi.waitFor(async () => {
880
+ const current = await prisma.session.findUniqueOrThrow({ where: { id: session.id } });
881
+ expect(current.status).toBe(SessionStatus.COMPLETED);
882
+ });
883
+ const runTurnEntered = deferred();
884
+ const runTurnRelease = deferred();
885
+ vi.mocked(driverSession.runTurn).mockImplementationOnce(async (turn) => {
886
+ runTurnEntered.resolve();
887
+ await runTurnRelease.promise;
888
+ turn.admissionSignal?.throwIfAborted();
889
+ return { completion: new Promise(() => undefined) };
890
+ });
891
+ const cleanupInput = await getPersistedCleanupInput(session.id, 'cleanup-retry-runtime-1');
892
+ const resend = manager.sendMessage(session.id, 'resend interrupted during driver handoff');
893
+ await runTurnEntered.promise;
894
+ const backgroundCleanup = manager.retryRuntimeProcessCleanup(cleanupInput);
895
+ await Promise.resolve();
896
+ expect(driverSession.close).not.toHaveBeenCalled();
897
+ runTurnRelease.resolve();
898
+ await expect(resend).rejects.toMatchObject({ code: 'runtime_admission_cancelled', retryable: true });
899
+ await expect(backgroundCleanup).resolves.toBeUndefined();
900
+ expect(driver.open).toHaveBeenCalledOnce();
901
+ expect(driverSession.close).toHaveBeenCalledOnce();
902
+ expect(new Set(observedCredentials).size).toBe(1);
903
+ expect(validateAgentApiCredential(observedCredentials[0])).toBeNull();
904
+ await expect(prisma.session.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({
905
+ runtimeLaunchState: 'SAFE_PRE_CHILD_FAILURE',
906
+ runtimeLaunchClaimCount: 3,
907
+ runtimeLaunchResolvedCount: 3,
908
+ runtimeLaunchProcessCount: 1,
909
+ });
910
+ await expect(prisma.executionProcess.findFirstOrThrow({
911
+ where: { sessionId: session.id, runtimeInstanceId: 'cleanup-retry-runtime-1' },
912
+ })).resolves.toMatchObject({ cleanupState: 'CONFIRMED' });
913
+ expect(await prisma.executionProcess.count({
914
+ where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
915
+ })).toBe(0);
525
916
  await manager.destroyAll();
526
917
  });
918
+ it.each(['start', 'startFollowUp'])('revokes a public %s before runtime admission when stop wins during preparation', async (method) => {
919
+ const { workspace, session } = await createSessionFixture();
920
+ const resumeSource = await prisma.session.create({
921
+ data: {
922
+ workspaceId: workspace.id,
923
+ agentType: AgentType.CODEX,
924
+ prompt: 'previous turn',
925
+ status: SessionStatus.COMPLETED,
926
+ externalSessionId: 'previous-external-session',
927
+ },
928
+ });
929
+ const manager = new SessionManager(new EventBus());
930
+ const coordinator = manager.runtimeCoordinator;
931
+ const withStartAdmission = vi.spyOn(coordinator, 'withStartAdmission');
932
+ const sessionRead = deferred();
933
+ const originalFindSession = manager.findSessionExecutionRecord.bind(manager);
934
+ vi.spyOn(manager, 'findSessionExecutionRecord').mockImplementation(async (sessionId) => {
935
+ const record = await originalFindSession(String(sessionId));
936
+ sessionRead.resolve();
937
+ return record;
938
+ });
939
+ const preparationGate = deferred();
940
+ manager.pendingAutoCommits.set(session.id, preparationGate.promise);
941
+ const starting = method === 'start'
942
+ ? manager.start(session.id)
943
+ : manager.startFollowUp(session.id, resumeSource.id);
944
+ const startingResult = expect(starting).rejects.toMatchObject({ code: 'SESSION_NOT_ADMITTED' });
945
+ await sessionRead.promise;
946
+ expect(withStartAdmission).not.toHaveBeenCalled();
947
+ await expect(manager.stop(session.id)).resolves.toMatchObject({ id: session.id });
948
+ await startingResult;
949
+ expect(withStartAdmission).not.toHaveBeenCalled();
950
+ expect(spawnMock).not.toHaveBeenCalled();
951
+ await expect(prisma.session.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({
952
+ status: SessionStatus.CANCELLED,
953
+ runtimeLaunchState: 'NOT_STARTED',
954
+ runtimeLaunchClaimCount: 0,
955
+ });
956
+ preparationGate.resolve();
957
+ expect(withStartAdmission).not.toHaveBeenCalled();
958
+ expect(spawnMock).not.toHaveBeenCalled();
959
+ const recoveryPty = new ControlledPty();
960
+ spawnMock.mockResolvedValueOnce(spawnResultFor(recoveryPty));
961
+ await expect(manager.sendMessage(session.id, 'recover after the completed stop'))
962
+ .resolves.toMatchObject({ id: session.id });
963
+ expect(spawnMock).toHaveBeenCalledOnce();
964
+ await manager.stop(session.id);
965
+ await manager.destroyAll();
966
+ });
967
+ it.each(['start', 'startFollowUp'])('does not start public %s preparation while stop intent is already in flight', async (method) => {
968
+ const { workspace, session } = await createSessionFixture();
969
+ const resumeSource = await prisma.session.create({
970
+ data: {
971
+ workspaceId: workspace.id,
972
+ agentType: AgentType.CODEX,
973
+ prompt: 'previous turn',
974
+ status: SessionStatus.COMPLETED,
975
+ },
976
+ });
977
+ const manager = new SessionManager(new EventBus());
978
+ const findSession = vi.spyOn(manager, 'findSessionExecutionRecord');
979
+ const waitForAutoCommit = vi.spyOn(manager, 'waitForPendingAutoCommit');
980
+ const stopBlocker = manager.reserveSessionAction(session.id);
981
+ const stopping = manager.stop(session.id);
982
+ try {
983
+ const starting = method === 'start'
984
+ ? manager.start(session.id)
985
+ : manager.startFollowUp(session.id, resumeSource.id);
986
+ await expect(starting).rejects.toMatchObject({ code: 'SESSION_NOT_ADMITTED' });
987
+ expect(findSession).not.toHaveBeenCalled();
988
+ expect(waitForAutoCommit).not.toHaveBeenCalled();
989
+ expect(spawnMock).not.toHaveBeenCalled();
990
+ }
991
+ finally {
992
+ stopBlocker.release();
993
+ await stopping;
994
+ await manager.destroyAll();
995
+ }
996
+ });
997
+ it.each(['start', 'startFollowUp'])('observes a late public %s preparation rejection after stop', async (method) => {
998
+ const { workspace, session } = await createSessionFixture();
999
+ const resumeSource = await prisma.session.create({
1000
+ data: {
1001
+ workspaceId: workspace.id,
1002
+ agentType: AgentType.CODEX,
1003
+ prompt: 'previous turn',
1004
+ status: SessionStatus.COMPLETED,
1005
+ },
1006
+ });
1007
+ const manager = new SessionManager(new EventBus());
1008
+ const queryStarted = deferred();
1009
+ const query = deferred();
1010
+ vi.spyOn(manager, 'findSessionExecutionRecord').mockImplementation(() => {
1011
+ queryStarted.resolve();
1012
+ return query.promise;
1013
+ });
1014
+ const unhandledRejections = [];
1015
+ const recordUnhandledRejection = (reason) => {
1016
+ unhandledRejections.push(reason);
1017
+ };
1018
+ process.on('unhandledRejection', recordUnhandledRejection);
1019
+ try {
1020
+ const starting = method === 'start'
1021
+ ? manager.start(session.id)
1022
+ : manager.startFollowUp(session.id, resumeSource.id);
1023
+ const startingResult = expect(starting).rejects.toMatchObject({ code: 'SESSION_NOT_ADMITTED' });
1024
+ await queryStarted.promise;
1025
+ await expect(manager.stop(session.id)).resolves.toMatchObject({ id: session.id });
1026
+ await startingResult;
1027
+ query.reject(new Error('late session lookup failure'));
1028
+ await new Promise((resolve) => setImmediate(resolve));
1029
+ expect(unhandledRejections).toEqual([]);
1030
+ expect(spawnMock).not.toHaveBeenCalled();
1031
+ }
1032
+ finally {
1033
+ process.off('unhandledRejection', recordUnhandledRejection);
1034
+ await manager.destroyAll();
1035
+ }
1036
+ });
527
1037
  afterAll(async () => {
528
1038
  await prisma.$disconnect();
529
1039
  fs.rmSync(testDir, { recursive: true, force: true });