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

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 (204) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +4 -0
  3. package/dist/app.js.map +1 -1
  4. package/dist/cli.js +39 -19
  5. package/dist/cli.js.map +1 -1
  6. package/dist/executors/__tests__/base.executor.test.js +133 -1
  7. package/dist/executors/__tests__/base.executor.test.js.map +1 -1
  8. package/dist/executors/__tests__/stdin-prompt.executors.test.js +18 -0
  9. package/dist/executors/__tests__/stdin-prompt.executors.test.js.map +1 -1
  10. package/dist/executors/base.executor.d.ts +8 -0
  11. package/dist/executors/base.executor.d.ts.map +1 -1
  12. package/dist/executors/base.executor.js +336 -56
  13. package/dist/executors/base.executor.js.map +1 -1
  14. package/dist/executors/index.d.ts +2 -1
  15. package/dist/executors/index.d.ts.map +1 -1
  16. package/dist/executors/index.js +1 -1
  17. package/dist/executors/index.js.map +1 -1
  18. package/dist/executors/start-error.d.ts +17 -0
  19. package/dist/executors/start-error.d.ts.map +1 -1
  20. package/dist/executors/start-error.js +36 -1
  21. package/dist/executors/start-error.js.map +1 -1
  22. package/dist/index.js +39 -29
  23. package/dist/index.js.map +1 -1
  24. package/dist/mcp/http-client.test.js +20 -0
  25. package/dist/mcp/http-client.test.js.map +1 -1
  26. package/dist/mcp/tools/__tests__/sessions.test.d.ts +2 -0
  27. package/dist/mcp/tools/__tests__/sessions.test.d.ts.map +1 -0
  28. package/dist/mcp/tools/__tests__/sessions.test.js +33 -0
  29. package/dist/mcp/tools/__tests__/sessions.test.js.map +1 -0
  30. package/dist/routes/__tests__/sessions-runtime.test.js +75 -3
  31. package/dist/routes/__tests__/sessions-runtime.test.js.map +1 -1
  32. package/dist/routes/conversations.d.ts.map +1 -1
  33. package/dist/routes/conversations.js +5 -1
  34. package/dist/routes/conversations.js.map +1 -1
  35. package/dist/routes/sessions.d.ts.map +1 -1
  36. package/dist/routes/sessions.js +13 -2
  37. package/dist/routes/sessions.js.map +1 -1
  38. package/dist/routes/team-runs.d.ts.map +1 -1
  39. package/dist/routes/team-runs.js +13 -1
  40. package/dist/routes/team-runs.js.map +1 -1
  41. package/dist/runtime/__tests__/acp-driver-lifecycle.test.js +86 -0
  42. package/dist/runtime/__tests__/acp-driver-lifecycle.test.js.map +1 -1
  43. package/dist/runtime/__tests__/acp-process-manager.test.js +393 -6
  44. package/dist/runtime/__tests__/acp-process-manager.test.js.map +1 -1
  45. package/dist/runtime/__tests__/cli-driver-lifecycle.test.d.ts +2 -0
  46. package/dist/runtime/__tests__/cli-driver-lifecycle.test.d.ts.map +1 -0
  47. package/dist/runtime/__tests__/cli-driver-lifecycle.test.js +244 -0
  48. package/dist/runtime/__tests__/cli-driver-lifecycle.test.js.map +1 -0
  49. package/dist/runtime/__tests__/launch-cleanup-registry.test.d.ts +2 -0
  50. package/dist/runtime/__tests__/launch-cleanup-registry.test.d.ts.map +1 -0
  51. package/dist/runtime/__tests__/launch-cleanup-registry.test.js +154 -0
  52. package/dist/runtime/__tests__/launch-cleanup-registry.test.js.map +1 -0
  53. package/dist/runtime/__tests__/runtime-coordinator.test.js +117 -1
  54. package/dist/runtime/__tests__/runtime-coordinator.test.js.map +1 -1
  55. package/dist/runtime/__tests__/server-entry-shutdown.test.d.ts +2 -0
  56. package/dist/runtime/__tests__/server-entry-shutdown.test.d.ts.map +1 -0
  57. package/dist/runtime/__tests__/server-entry-shutdown.test.js +38 -0
  58. package/dist/runtime/__tests__/server-entry-shutdown.test.js.map +1 -0
  59. package/dist/runtime/__tests__/shutdown-coordinator.test.d.ts +2 -0
  60. package/dist/runtime/__tests__/shutdown-coordinator.test.d.ts.map +1 -0
  61. package/dist/runtime/__tests__/shutdown-coordinator.test.js +28 -0
  62. package/dist/runtime/__tests__/shutdown-coordinator.test.js.map +1 -0
  63. package/dist/runtime/acp/acp-driver.d.ts.map +1 -1
  64. package/dist/runtime/acp/acp-driver.js +108 -35
  65. package/dist/runtime/acp/acp-driver.js.map +1 -1
  66. package/dist/runtime/acp/agents/managed-directory.d.ts.map +1 -1
  67. package/dist/runtime/acp/agents/managed-directory.js +3 -1
  68. package/dist/runtime/acp/agents/managed-directory.js.map +1 -1
  69. package/dist/runtime/acp/agents/managed-directory.test.d.ts +2 -0
  70. package/dist/runtime/acp/agents/managed-directory.test.d.ts.map +1 -0
  71. package/dist/runtime/acp/agents/managed-directory.test.js +25 -0
  72. package/dist/runtime/acp/agents/managed-directory.test.js.map +1 -0
  73. package/dist/runtime/acp/launch-cleanup-registry.d.ts +32 -0
  74. package/dist/runtime/acp/launch-cleanup-registry.d.ts.map +1 -0
  75. package/dist/runtime/acp/launch-cleanup-registry.js +176 -0
  76. package/dist/runtime/acp/launch-cleanup-registry.js.map +1 -0
  77. package/dist/runtime/acp/process-manager.d.ts +58 -0
  78. package/dist/runtime/acp/process-manager.d.ts.map +1 -1
  79. package/dist/runtime/acp/process-manager.js +489 -33
  80. package/dist/runtime/acp/process-manager.js.map +1 -1
  81. package/dist/runtime/cli-driver.d.ts.map +1 -1
  82. package/dist/runtime/cli-driver.js +230 -46
  83. package/dist/runtime/cli-driver.js.map +1 -1
  84. package/dist/runtime/contracts.d.ts +21 -2
  85. package/dist/runtime/contracts.d.ts.map +1 -1
  86. package/dist/runtime/index.d.ts +1 -0
  87. package/dist/runtime/index.d.ts.map +1 -1
  88. package/dist/runtime/index.js +1 -0
  89. package/dist/runtime/index.js.map +1 -1
  90. package/dist/runtime/runtime-coordinator.d.ts +8 -1
  91. package/dist/runtime/runtime-coordinator.d.ts.map +1 -1
  92. package/dist/runtime/runtime-coordinator.js +118 -18
  93. package/dist/runtime/runtime-coordinator.js.map +1 -1
  94. package/dist/runtime/server-entry-shutdown.d.ts +12 -0
  95. package/dist/runtime/server-entry-shutdown.d.ts.map +1 -0
  96. package/dist/runtime/server-entry-shutdown.js +23 -0
  97. package/dist/runtime/server-entry-shutdown.js.map +1 -0
  98. package/dist/runtime/shutdown-coordinator.d.ts +17 -0
  99. package/dist/runtime/shutdown-coordinator.d.ts.map +1 -0
  100. package/dist/runtime/shutdown-coordinator.js +67 -0
  101. package/dist/runtime/shutdown-coordinator.js.map +1 -0
  102. package/dist/services/__tests__/conversation.service.test.js +160 -0
  103. package/dist/services/__tests__/conversation.service.test.js.map +1 -1
  104. package/dist/services/__tests__/database-maintenance.service.test.js +97 -0
  105. package/dist/services/__tests__/database-maintenance.service.test.js.map +1 -1
  106. package/dist/services/__tests__/member-heartbeat-scheduler.test.js +6 -0
  107. package/dist/services/__tests__/member-heartbeat-scheduler.test.js.map +1 -1
  108. package/dist/services/__tests__/member-heartbeat.test.js +7 -11
  109. package/dist/services/__tests__/member-heartbeat.test.js.map +1 -1
  110. package/dist/services/__tests__/session-manager.lifecycle.test.js +402 -12
  111. package/dist/services/__tests__/session-manager.lifecycle.test.js.map +1 -1
  112. package/dist/services/__tests__/session-manager.team-run.test.js +422 -29
  113. package/dist/services/__tests__/session-manager.team-run.test.js.map +1 -1
  114. package/dist/services/__tests__/team-queue-recovery.integration.test.js +1 -0
  115. package/dist/services/__tests__/team-queue-recovery.integration.test.js.map +1 -1
  116. package/dist/services/__tests__/team-reconciler.service.test.js +1282 -4
  117. package/dist/services/__tests__/team-reconciler.service.test.js.map +1 -1
  118. package/dist/services/__tests__/team-run.service.test.js +161 -1
  119. package/dist/services/__tests__/team-run.service.test.js.map +1 -1
  120. package/dist/services/__tests__/team-scheduler.service.test.js +334 -6
  121. package/dist/services/__tests__/team-scheduler.service.test.js.map +1 -1
  122. package/dist/services/conversation.service.d.ts.map +1 -1
  123. package/dist/services/conversation.service.js +21 -2
  124. package/dist/services/conversation.service.js.map +1 -1
  125. package/dist/services/database-maintenance.service.d.ts.map +1 -1
  126. package/dist/services/database-maintenance.service.js +68 -17
  127. package/dist/services/database-maintenance.service.js.map +1 -1
  128. package/dist/services/member-heartbeat-scheduler.d.ts +6 -5
  129. package/dist/services/member-heartbeat-scheduler.d.ts.map +1 -1
  130. package/dist/services/member-heartbeat-scheduler.js +7 -5
  131. package/dist/services/member-heartbeat-scheduler.js.map +1 -1
  132. package/dist/services/session-manager.d.ts +124 -14
  133. package/dist/services/session-manager.d.ts.map +1 -1
  134. package/dist/services/session-manager.js +1035 -116
  135. package/dist/services/session-manager.js.map +1 -1
  136. package/dist/services/session-runtime-cleanup-gate.d.ts +25 -0
  137. package/dist/services/session-runtime-cleanup-gate.d.ts.map +1 -0
  138. package/dist/services/session-runtime-cleanup-gate.js +284 -0
  139. package/dist/services/session-runtime-cleanup-gate.js.map +1 -0
  140. package/dist/services/task.service.d.ts +2 -2
  141. package/dist/services/team-member-admission-barrier.d.ts +3 -0
  142. package/dist/services/team-member-admission-barrier.d.ts.map +1 -0
  143. package/dist/services/team-member-admission-barrier.js +26 -0
  144. package/dist/services/team-member-admission-barrier.js.map +1 -0
  145. package/dist/services/team-reconciler.service.d.ts +30 -7
  146. package/dist/services/team-reconciler.service.d.ts.map +1 -1
  147. package/dist/services/team-reconciler.service.js +528 -111
  148. package/dist/services/team-reconciler.service.js.map +1 -1
  149. package/dist/services/team-run.service.d.ts.map +1 -1
  150. package/dist/services/team-run.service.js +37 -13
  151. package/dist/services/team-run.service.js.map +1 -1
  152. package/dist/services/team-scheduler.service.d.ts +8 -6
  153. package/dist/services/team-scheduler.service.d.ts.map +1 -1
  154. package/dist/services/team-scheduler.service.js +323 -120
  155. package/dist/services/team-scheduler.service.js.map +1 -1
  156. package/dist/services/workspace-background-process-manager.d.ts.map +1 -1
  157. package/dist/services/workspace-background-process-manager.js +2 -5
  158. package/dist/services/workspace-background-process-manager.js.map +1 -1
  159. package/dist/services/workspace.service.d.ts +15 -8
  160. package/dist/services/workspace.service.d.ts.map +1 -1
  161. package/dist/socket/index.d.ts.map +1 -1
  162. package/dist/socket/index.js +27 -14
  163. package/dist/socket/index.js.map +1 -1
  164. package/dist/utils/__tests__/unix-process-identity.test.d.ts +2 -0
  165. package/dist/utils/__tests__/unix-process-identity.test.d.ts.map +1 -0
  166. package/dist/utils/__tests__/unix-process-identity.test.js +73 -0
  167. package/dist/utils/__tests__/unix-process-identity.test.js.map +1 -0
  168. package/dist/utils/error-log.d.ts +2 -0
  169. package/dist/utils/error-log.d.ts.map +1 -1
  170. package/dist/utils/error-log.js +40 -3
  171. package/dist/utils/error-log.js.map +1 -1
  172. package/dist/utils/error-log.test.js +14 -2
  173. package/dist/utils/error-log.test.js.map +1 -1
  174. package/dist/utils/process-launch.d.ts +2 -2
  175. package/dist/utils/process-launch.d.ts.map +1 -1
  176. package/dist/utils/process-launch.js +339 -79
  177. package/dist/utils/process-launch.js.map +1 -1
  178. package/dist/utils/process-launch.test.js +168 -10
  179. package/dist/utils/process-launch.test.js.map +1 -1
  180. package/dist/utils/spawned-process-identity.d.ts +7 -0
  181. package/dist/utils/spawned-process-identity.d.ts.map +1 -0
  182. package/dist/utils/spawned-process-identity.js +36 -0
  183. package/dist/utils/spawned-process-identity.js.map +1 -0
  184. package/dist/utils/tree-cleanup-channel.d.ts +18 -0
  185. package/dist/utils/tree-cleanup-channel.d.ts.map +1 -0
  186. package/dist/utils/tree-cleanup-channel.js +16 -0
  187. package/dist/utils/tree-cleanup-channel.js.map +1 -0
  188. package/dist/utils/tree-cleanup-channel.test.d.ts +2 -0
  189. package/dist/utils/tree-cleanup-channel.test.d.ts.map +1 -0
  190. package/dist/utils/tree-cleanup-channel.test.js +15 -0
  191. package/dist/utils/tree-cleanup-channel.test.js.map +1 -0
  192. package/dist/utils/unix-process-identity.d.ts +13 -1
  193. package/dist/utils/unix-process-identity.d.ts.map +1 -1
  194. package/dist/utils/unix-process-identity.js +146 -25
  195. package/dist/utils/unix-process-identity.js.map +1 -1
  196. package/node_modules/@agent-tower/shared/dist/types.d.ts +1 -0
  197. package/node_modules/@agent-tower/shared/dist/types.d.ts.map +1 -1
  198. package/node_modules/@agent-tower/shared/dist/types.js.map +1 -1
  199. package/package.json +1 -1
  200. package/prisma/migrations/20260824000000_add_execution_process_ownership/migration.sql +10 -0
  201. package/prisma/migrations/20260824010000_add_runtime_cleanup_recovery/migration.sql +10 -0
  202. package/prisma/migrations/20260824020000_add_runtime_launch_admission/migration.sql +72 -0
  203. package/prisma/migrations/20260826000000_add_conversation_turn_queue/migration.sql +19 -0
  204. package/prisma/schema.prisma +50 -7
@@ -56,12 +56,14 @@ let SessionManager;
56
56
  let sessionMsgStoreManager;
57
57
  let WorkspaceBackgroundService;
58
58
  let WorkspaceBackgroundProcessManager;
59
+ let markPreChildProcessFailure;
59
60
  /** 可手动触发事件的 fake PTY,语义对齐 node-pty(不重放事件) */
60
61
  class ControlledPty {
61
62
  pid = 4242;
62
63
  killed = false;
63
64
  dataListeners = [];
64
65
  exitListeners = [];
66
+ exited = false;
65
67
  onData = (cb) => {
66
68
  this.dataListeners.push(cb);
67
69
  return { dispose: () => { this.dataListeners = this.dataListeners.filter((l) => l !== cb); } };
@@ -75,17 +77,26 @@ class ControlledPty {
75
77
  l(data);
76
78
  }
77
79
  emitExit(exitCode) {
80
+ if (this.exited)
81
+ return;
82
+ this.exited = true;
78
83
  for (const l of [...this.exitListeners])
79
84
  l({ exitCode });
80
85
  }
81
86
  write() { }
82
87
  resize() { }
83
- kill() { this.killed = true; }
88
+ kill() {
89
+ this.killed = true;
90
+ this.emitExit(0);
91
+ }
84
92
  }
85
93
  function spawnResultFor(pty, earlyEvents = []) {
86
94
  let taken = false;
87
95
  return {
88
96
  pid: pty.pid,
97
+ processGroupId: String(pty.pid),
98
+ birthMarker: `test-birth:${pty.pid}`,
99
+ ownershipToken: `test-owner:${pty.pid}`,
89
100
  pty,
90
101
  takeEarlyEvents: () => {
91
102
  if (taken)
@@ -152,11 +163,13 @@ describe('SessionManager session status vs real process state', () => {
152
163
  const outputModule = await import('../../output/index.js');
153
164
  const backgroundServiceModule = await import('../workspace-background-service.service.js');
154
165
  const backgroundManagerModule = await import('../workspace-background-process-manager.js');
166
+ const startErrorModule = await import('../../executors/start-error.js');
155
167
  prisma = utilsModule.prisma;
156
168
  SessionManager = sessionManagerModule.SessionManager;
157
169
  sessionMsgStoreManager = outputModule.sessionMsgStoreManager;
158
170
  WorkspaceBackgroundService = backgroundServiceModule.WorkspaceBackgroundService;
159
171
  WorkspaceBackgroundProcessManager = backgroundManagerModule.WorkspaceBackgroundProcessManager;
172
+ markPreChildProcessFailure = startErrorModule.markPreChildProcessFailure;
160
173
  });
161
174
  beforeEach(async () => {
162
175
  vi.clearAllMocks();
@@ -165,6 +178,7 @@ describe('SessionManager session status vs real process state', () => {
165
178
  getExecutorByProviderMock.mockImplementation(createMockExecutor);
166
179
  clearAgentApiCredentials();
167
180
  await prisma.executionProcess.deleteMany();
181
+ await prisma.conversation.deleteMany();
168
182
  await prisma.session.deleteMany();
169
183
  await prisma.workspace.deleteMany();
170
184
  await prisma.task.deleteMany();
@@ -218,7 +232,7 @@ describe('SessionManager session status vs real process state', () => {
218
232
  expect(getExecutorByProviderMock).toHaveBeenNthCalledWith(1, 'provider-snapshot');
219
233
  expect(getExecutorByProviderMock).toHaveBeenNthCalledWith(2, 'provider-snapshot');
220
234
  expect(executorSnapshots).toEqual([false, true]);
221
- manager.destroyAll();
235
+ await manager.destroyAll();
222
236
  });
223
237
  it('uses one latest Provider snapshot for follow-up resume and its new-session fallback', async () => {
224
238
  const provider = {
@@ -241,7 +255,7 @@ describe('SessionManager session status vs real process state', () => {
241
255
  spawnFollowUp: vi.fn(async () => {
242
256
  observed.push({ path: 'resume', disabled });
243
257
  provider.config.disableResponsesWebsocket = false;
244
- throw new Error('synthetic resume failure');
258
+ throw markPreChildProcessFailure(new Error('synthetic resume failure'));
245
259
  }),
246
260
  spawn: vi.fn(async () => {
247
261
  observed.push({ path: 'fallback', disabled });
@@ -261,7 +275,40 @@ describe('SessionManager session status vs real process state', () => {
261
275
  { path: 'resume', disabled: true },
262
276
  { path: 'fallback', disabled: true },
263
277
  ]);
264
- manager.destroyAll();
278
+ await expect(prisma.session.findUnique({ where: { id: session.id } })).resolves.toMatchObject({
279
+ runtimeLaunchState: 'PROCESS_RECORDED',
280
+ runtimeLaunchClaimCount: 1,
281
+ runtimeLaunchResolvedCount: 1,
282
+ runtimeLaunchProcessCount: 1,
283
+ });
284
+ await manager.destroyAll();
285
+ });
286
+ it('keeps terminal conversation follow-ups compatible without a TeamRun invocation identity', async () => {
287
+ const conversation = await prisma.conversation.create({
288
+ data: {
289
+ title: 'Lifecycle conversation',
290
+ directoryName: `lifecycle-conversation-${Date.now()}`,
291
+ workingDir: testDir,
292
+ session: {
293
+ create: {
294
+ context: 'CONVERSATION',
295
+ agentType: AgentType.CODEX,
296
+ variant: 'DEFAULT',
297
+ prompt: 'initial question',
298
+ status: SessionStatus.COMPLETED,
299
+ },
300
+ },
301
+ },
302
+ include: { session: true },
303
+ });
304
+ spawnMock.mockResolvedValueOnce(spawnResultFor(new ControlledPty()));
305
+ const manager = new SessionManager(new EventBus());
306
+ await expect(manager.sendMessage(conversation.session.id, 'follow-up question'))
307
+ .resolves.toMatchObject({ id: conversation.session.id });
308
+ expect(spawnMock).toHaveBeenCalledTimes(1);
309
+ await expect(prisma.session.findUnique({ where: { id: conversation.session.id } }))
310
+ .resolves.toMatchObject({ status: SessionStatus.RUNNING });
311
+ await manager.destroyAll();
265
312
  });
266
313
  it('marks a reused ACP follow-up RUNNING without a new process event and allows it to stop', async () => {
267
314
  const provider = {
@@ -280,6 +327,10 @@ describe('SessionManager session status vs real process state', () => {
280
327
  data: {
281
328
  runtimeType: 'ACP',
282
329
  status: SessionStatus.COMPLETED,
330
+ runtimeLaunchState: 'PROCESS_RECORDED',
331
+ runtimeLaunchClaimCount: 1,
332
+ runtimeLaunchResolvedCount: 1,
333
+ runtimeLaunchProcessCount: 1,
283
334
  externalSessionId: 'external-acp-session',
284
335
  logSnapshot: JSON.stringify({
285
336
  sessionId: 'external-acp-session',
@@ -288,14 +339,35 @@ describe('SessionManager session status vs real process state', () => {
288
339
  }),
289
340
  },
290
341
  });
342
+ await prisma.executionProcess.create({
343
+ data: {
344
+ sessionId: session.id,
345
+ launchClaimNumber: 1,
346
+ runtimeInstanceId: 'reused-acp-runtime',
347
+ pid: 4301,
348
+ processGroupId: '4301',
349
+ birthMarker: 'test-birth:4301',
350
+ ownershipToken: 'test-owner:4301',
351
+ cleanupState: 'ACTIVE',
352
+ },
353
+ });
291
354
  const completion = new Promise(() => undefined);
292
- const hasActiveTurn = vi.fn().mockReturnValueOnce(false).mockReturnValue(true);
355
+ const hasActiveTurn = vi.fn()
356
+ .mockReturnValueOnce(false)
357
+ .mockReturnValueOnce(true)
358
+ .mockReturnValue(false);
293
359
  const runtimeCoordinator = {
294
360
  hasActiveTurn,
295
361
  startTurn: vi.fn(async (_input) => ({ turnId: 'turn-2', completion })),
296
362
  abandonTurn: vi.fn(async () => true),
297
363
  cancelTurn: vi.fn(async () => undefined),
298
- disposeSession: vi.fn(async () => undefined),
364
+ disposeSession: vi.fn(async () => {
365
+ await prisma.executionProcess.updateMany({
366
+ where: { sessionId: session.id },
367
+ data: { cleanupState: 'CONFIRMED' },
368
+ });
369
+ }),
370
+ hasRuntimeProcessOwner: vi.fn(() => false),
299
371
  destroyAll: vi.fn(async () => undefined),
300
372
  };
301
373
  const manager = new SessionManager(new EventBus());
@@ -314,7 +386,13 @@ describe('SessionManager session status vs real process state', () => {
314
386
  .toBe(startInput?.historyBoundaryEntryId);
315
387
  expect((await prisma.session.findUnique({ where: { id: session.id } }))?.status)
316
388
  .toBe(SessionStatus.RUNNING);
317
- expect(await prisma.executionProcess.count({ where: { sessionId: session.id } })).toBe(0);
389
+ await expect(prisma.session.findUnique({ where: { id: session.id } })).resolves.toMatchObject({
390
+ runtimeLaunchState: 'REUSED',
391
+ runtimeLaunchClaimCount: 2,
392
+ runtimeLaunchResolvedCount: 2,
393
+ runtimeLaunchProcessCount: 1,
394
+ });
395
+ expect(await prisma.executionProcess.count({ where: { sessionId: session.id } })).toBe(1);
318
396
  // Even if a stale terminal value is observed, an active Runtime turn wins.
319
397
  await prisma.session.update({
320
398
  where: { id: session.id },
@@ -669,7 +747,7 @@ describe('SessionManager session status vs real process state', () => {
669
747
  providerId: provider.id,
670
748
  status: SessionStatus.RUNNING,
671
749
  });
672
- manager.destroyAll();
750
+ await manager.destroyAll();
673
751
  });
674
752
  it('keeps completed-turn post-processing alive when a follow-up provider was deleted', async () => {
675
753
  const { task, workspace, session } = await createSessionFixture();
@@ -692,11 +770,11 @@ describe('SessionManager session status vs real process state', () => {
692
770
  await completed;
693
771
  await expect(manager.sendMessage(session.id, 'follow-up', 'deleted-provider'))
694
772
  .rejects.toThrow('Provider not found: deleted-provider');
695
- expect(autoCommitSpy).toHaveBeenCalledTimes(1);
773
+ await vi.waitFor(() => expect(autoCommitSpy).toHaveBeenCalledTimes(1));
696
774
  expect(spawnMock).toHaveBeenCalledTimes(1);
697
775
  releaseAutoCommit();
698
776
  await vi.waitFor(async () => {
699
- expect(reconcileSpy).toHaveBeenCalledWith(session.id);
777
+ expect(reconcileSpy).toHaveBeenCalledWith(session.id, expect.any(String));
700
778
  expect((await prisma.task.findUnique({ where: { id: task.id } }))?.status)
701
779
  .toBe(TaskStatus.IN_REVIEW);
702
780
  expect(commitMessageSpy).toHaveBeenCalledWith(workspace.id);
@@ -735,7 +813,7 @@ describe('SessionManager session status vs real process state', () => {
735
813
  expect(spawnMock).toHaveBeenCalledTimes(1);
736
814
  releaseAutoCommit();
737
815
  await vi.waitFor(async () => {
738
- expect(reconcileSpy).toHaveBeenCalledWith(session.id);
816
+ expect(reconcileSpy).toHaveBeenCalledWith(session.id, expect.any(String));
739
817
  expect((await prisma.task.findUnique({ where: { id: task.id } }))?.status)
740
818
  .toBe(TaskStatus.IN_REVIEW);
741
819
  expect(commitMessageSpy).toHaveBeenCalledWith(workspace.id);
@@ -794,7 +872,7 @@ describe('SessionManager session status vs real process state', () => {
794
872
  await followUp;
795
873
  expect(reconcileSpy).not.toHaveBeenCalled();
796
874
  expect(spawnMock).toHaveBeenCalledTimes(2);
797
- manager.destroyAll();
875
+ await manager.destroyAll();
798
876
  });
799
877
  it('keeps turn.failed terminal when the wrapper exits with code 0', async () => {
800
878
  const { task, session } = await createSessionFixture();
@@ -936,5 +1014,317 @@ describe('SessionManager session status vs real process state', () => {
936
1014
  await backgroundManager.stopAll().catch(() => undefined);
937
1015
  }
938
1016
  }, 15_000);
1017
+ it('does not confirm ACP tree cleanup on root exit or let a late old cleanup overwrite a new runtime', async () => {
1018
+ const { session } = await createSessionFixture();
1019
+ const manager = new SessionManager(new EventBus());
1020
+ const handleProcessEvent = manager.handleRuntimeProcessEvent.bind(manager);
1021
+ await prisma.session.update({
1022
+ where: { id: session.id },
1023
+ data: {
1024
+ status: SessionStatus.RUNNING,
1025
+ runtimeLaunchState: 'CLAIMED',
1026
+ runtimeLaunchClaimCount: 1,
1027
+ },
1028
+ });
1029
+ await handleProcessEvent({
1030
+ type: 'started',
1031
+ towerSessionId: session.id,
1032
+ runtimeInstanceId: 'runtime-old',
1033
+ launchClaimNumber: 1,
1034
+ pid: 4101,
1035
+ processGroupId: '4101',
1036
+ birthMarker: 'darwin:old:token-old',
1037
+ ownershipToken: 'token-old',
1038
+ });
1039
+ await handleProcessEvent({
1040
+ type: 'exited',
1041
+ towerSessionId: session.id,
1042
+ runtimeInstanceId: 'runtime-old',
1043
+ exitCode: 0,
1044
+ launchClaimNumber: 1,
1045
+ });
1046
+ await expect(prisma.executionProcess.findFirst({
1047
+ where: { sessionId: session.id, runtimeInstanceId: 'runtime-old' },
1048
+ })).resolves.toMatchObject({ exitCode: 0, cleanupState: 'PENDING' });
1049
+ await prisma.session.update({
1050
+ where: { id: session.id },
1051
+ data: {
1052
+ runtimeLaunchState: 'CLAIMED',
1053
+ runtimeLaunchClaimCount: 2,
1054
+ },
1055
+ });
1056
+ await handleProcessEvent({
1057
+ type: 'started',
1058
+ towerSessionId: session.id,
1059
+ runtimeInstanceId: 'runtime-new',
1060
+ launchClaimNumber: 2,
1061
+ pid: 4102,
1062
+ processGroupId: '4102',
1063
+ birthMarker: 'darwin:new:token-new',
1064
+ ownershipToken: 'token-new',
1065
+ });
1066
+ await handleProcessEvent({
1067
+ type: 'exited',
1068
+ towerSessionId: session.id,
1069
+ runtimeInstanceId: 'runtime-shared',
1070
+ launchClaimNumber: 1,
1071
+ exitCode: 0,
1072
+ signal: null,
1073
+ });
1074
+ await expect(prisma.executionProcess.findFirstOrThrow({
1075
+ where: { sessionId: session.id, launchClaimNumber: 1 },
1076
+ })).resolves.toMatchObject({ cleanupState: 'PENDING' });
1077
+ await expect(prisma.executionProcess.findFirstOrThrow({
1078
+ where: { sessionId: session.id, launchClaimNumber: 2 },
1079
+ })).resolves.toMatchObject({ cleanupState: 'ACTIVE' });
1080
+ await handleProcessEvent({
1081
+ type: 'tree_cleanup_completed',
1082
+ towerSessionId: session.id,
1083
+ runtimeInstanceId: 'runtime-old',
1084
+ launchClaimNumber: 1,
1085
+ });
1086
+ await handleProcessEvent({
1087
+ type: 'exited',
1088
+ towerSessionId: session.id,
1089
+ runtimeInstanceId: 'runtime-old',
1090
+ exitCode: 0,
1091
+ launchClaimNumber: 1,
1092
+ });
1093
+ await expect(prisma.executionProcess.findFirst({
1094
+ where: { sessionId: session.id, runtimeInstanceId: 'runtime-old' },
1095
+ })).resolves.toMatchObject({ cleanupState: 'CONFIRMED' });
1096
+ await expect(prisma.executionProcess.findFirst({
1097
+ where: { sessionId: session.id, runtimeInstanceId: 'runtime-new' },
1098
+ })).resolves.toMatchObject({ cleanupState: 'ACTIVE' });
1099
+ await manager.destroyAll();
1100
+ });
1101
+ it('requires the launch claim when cleanup events share a runtime instance id', async () => {
1102
+ const { session } = await createSessionFixture();
1103
+ const manager = new SessionManager(new EventBus());
1104
+ const handleProcessEvent = manager.handleRuntimeProcessEvent.bind(manager);
1105
+ await prisma.session.update({
1106
+ where: { id: session.id },
1107
+ data: {
1108
+ status: SessionStatus.RUNNING,
1109
+ runtimeLaunchState: 'CLAIMED',
1110
+ runtimeLaunchClaimCount: 1,
1111
+ },
1112
+ });
1113
+ await handleProcessEvent({
1114
+ type: 'started',
1115
+ towerSessionId: session.id,
1116
+ runtimeInstanceId: 'runtime-shared',
1117
+ launchClaimNumber: 1,
1118
+ pid: 4111,
1119
+ processGroupId: '4111',
1120
+ birthMarker: 'darwin:shared:one',
1121
+ ownershipToken: 'token-shared-one',
1122
+ });
1123
+ await prisma.session.update({
1124
+ where: { id: session.id },
1125
+ data: {
1126
+ runtimeLaunchState: 'CLAIMED',
1127
+ runtimeLaunchClaimCount: 2,
1128
+ },
1129
+ });
1130
+ await handleProcessEvent({
1131
+ type: 'started',
1132
+ towerSessionId: session.id,
1133
+ runtimeInstanceId: 'runtime-shared',
1134
+ launchClaimNumber: 2,
1135
+ pid: 4112,
1136
+ processGroupId: '4112',
1137
+ birthMarker: 'darwin:shared:two',
1138
+ ownershipToken: 'token-shared-two',
1139
+ });
1140
+ // A late event without the generation claim cannot be assigned to either
1141
+ // owner, even when runtimeInstanceId is shared across reconnects.
1142
+ await handleProcessEvent({
1143
+ type: 'tree_cleanup_completed',
1144
+ towerSessionId: session.id,
1145
+ runtimeInstanceId: 'runtime-shared',
1146
+ });
1147
+ await expect(prisma.executionProcess.findMany({
1148
+ where: { sessionId: session.id, runtimeInstanceId: 'runtime-shared' },
1149
+ orderBy: { launchClaimNumber: 'asc' },
1150
+ })).resolves.toMatchObject([
1151
+ { launchClaimNumber: 1, cleanupState: 'ACTIVE' },
1152
+ { launchClaimNumber: 2, cleanupState: 'ACTIVE' },
1153
+ ]);
1154
+ await handleProcessEvent({
1155
+ type: 'tree_cleanup_completed',
1156
+ towerSessionId: session.id,
1157
+ runtimeInstanceId: 'runtime-shared',
1158
+ launchClaimNumber: 1,
1159
+ });
1160
+ await expect(prisma.executionProcess.findFirstOrThrow({
1161
+ where: { sessionId: session.id, launchClaimNumber: 1 },
1162
+ })).resolves.toMatchObject({ cleanupState: 'CONFIRMED' });
1163
+ await expect(prisma.executionProcess.findFirstOrThrow({
1164
+ where: { sessionId: session.id, launchClaimNumber: 2 },
1165
+ })).resolves.toMatchObject({ cleanupState: 'ACTIVE' });
1166
+ await handleProcessEvent({
1167
+ type: 'tree_cleanup_completed',
1168
+ towerSessionId: session.id,
1169
+ runtimeInstanceId: 'runtime-shared',
1170
+ launchClaimNumber: 2,
1171
+ });
1172
+ await expect(prisma.executionProcess.findMany({
1173
+ where: { sessionId: session.id, runtimeInstanceId: 'runtime-shared' },
1174
+ orderBy: { launchClaimNumber: 'asc' },
1175
+ })).resolves.toMatchObject([
1176
+ { launchClaimNumber: 1, cleanupState: 'CONFIRMED' },
1177
+ { launchClaimNumber: 2, cleanupState: 'CONFIRMED' },
1178
+ ]);
1179
+ await manager.destroyAll();
1180
+ });
1181
+ it('replays early process events after deferred started persistence', async () => {
1182
+ const { session } = await createSessionFixture();
1183
+ const manager = new SessionManager(new EventBus());
1184
+ const handleProcessEvent = manager.handleRuntimeProcessEvent.bind(manager);
1185
+ // Simulate the raw wrapper exit arriving before the started transaction.
1186
+ await handleProcessEvent({
1187
+ type: 'exited',
1188
+ towerSessionId: session.id,
1189
+ runtimeInstanceId: 'runtime-deferred',
1190
+ exitCode: 0,
1191
+ launchClaimNumber: 1,
1192
+ });
1193
+ await handleProcessEvent({
1194
+ type: 'tree_cleanup_completed',
1195
+ towerSessionId: session.id,
1196
+ runtimeInstanceId: 'runtime-deferred',
1197
+ launchClaimNumber: 1,
1198
+ });
1199
+ await expect(prisma.session.findUnique({ where: { id: session.id } }))
1200
+ .resolves.toMatchObject({
1201
+ runtimeLaunchState: 'QUARANTINED',
1202
+ runtimeLaunchDiagnostic: expect.stringContaining('before started persistence'),
1203
+ });
1204
+ await prisma.session.update({
1205
+ where: { id: session.id },
1206
+ data: {
1207
+ status: SessionStatus.RUNNING,
1208
+ runtimeLaunchState: 'CLAIMED',
1209
+ runtimeLaunchClaimCount: 1,
1210
+ },
1211
+ });
1212
+ await handleProcessEvent({
1213
+ type: 'started',
1214
+ towerSessionId: session.id,
1215
+ runtimeInstanceId: 'runtime-deferred',
1216
+ launchClaimNumber: 1,
1217
+ pid: 4301,
1218
+ processGroupId: '4301',
1219
+ birthMarker: 'linux:deferred',
1220
+ ownershipToken: 'owner-deferred',
1221
+ });
1222
+ await expect(prisma.executionProcess.findFirstOrThrow({
1223
+ where: { sessionId: session.id, runtimeInstanceId: 'runtime-deferred' },
1224
+ })).resolves.toMatchObject({ exitCode: 0, cleanupState: 'CONFIRMED' });
1225
+ expect(manager.hasRuntimeProcessOwner('runtime-deferred')).toBe(false);
1226
+ await manager.destroyAll();
1227
+ });
1228
+ it('does not let late runtime events downgrade incomplete ownership quarantine', async () => {
1229
+ const { session } = await createSessionFixture();
1230
+ const manager = new SessionManager(new EventBus());
1231
+ const handleProcessEvent = manager.handleRuntimeProcessEvent.bind(manager);
1232
+ await prisma.session.update({
1233
+ where: { id: session.id },
1234
+ data: {
1235
+ status: SessionStatus.RUNNING,
1236
+ runtimeLaunchState: 'CLAIMED',
1237
+ runtimeLaunchClaimCount: 1,
1238
+ },
1239
+ });
1240
+ await handleProcessEvent({
1241
+ type: 'started',
1242
+ towerSessionId: session.id,
1243
+ runtimeInstanceId: 'runtime-incomplete',
1244
+ launchClaimNumber: 1,
1245
+ pid: 4151,
1246
+ processGroupId: '4151',
1247
+ birthMarker: '',
1248
+ ownershipToken: 'token-incomplete',
1249
+ });
1250
+ await handleProcessEvent({
1251
+ type: 'exited',
1252
+ towerSessionId: session.id,
1253
+ runtimeInstanceId: 'runtime-incomplete',
1254
+ exitCode: 0,
1255
+ launchClaimNumber: 1,
1256
+ });
1257
+ await handleProcessEvent({
1258
+ type: 'tree_cleanup_completed',
1259
+ towerSessionId: session.id,
1260
+ runtimeInstanceId: 'runtime-incomplete',
1261
+ launchClaimNumber: 1,
1262
+ });
1263
+ await expect(prisma.executionProcess.findFirstOrThrow({
1264
+ where: { sessionId: session.id, runtimeInstanceId: 'runtime-incomplete' },
1265
+ })).resolves.toMatchObject({
1266
+ cleanupState: 'QUARANTINED',
1267
+ cleanupError: expect.stringContaining('complete process ownership identity'),
1268
+ });
1269
+ await manager.destroyAll();
1270
+ });
1271
+ it('bounds pending process events per generation and expires them', async () => {
1272
+ vi.useFakeTimers();
1273
+ try {
1274
+ const { session } = await createSessionFixture();
1275
+ const manager = new SessionManager(new EventBus());
1276
+ const handleProcessEvent = manager;
1277
+ for (let index = 0; index < 9; index += 1) {
1278
+ await handleProcessEvent.handleRuntimeProcessEvent({
1279
+ type: 'exited',
1280
+ towerSessionId: session.id,
1281
+ runtimeInstanceId: 'runtime-overflow',
1282
+ launchClaimNumber: 1,
1283
+ exitCode: 1,
1284
+ });
1285
+ }
1286
+ expect(handleProcessEvent.pendingRuntimeProcessEvents.size).toBe(0);
1287
+ await handleProcessEvent.handleRuntimeProcessEvent({
1288
+ type: 'exited',
1289
+ towerSessionId: session.id,
1290
+ runtimeInstanceId: 'runtime-expiry',
1291
+ launchClaimNumber: 2,
1292
+ exitCode: 1,
1293
+ });
1294
+ expect(handleProcessEvent.pendingRuntimeProcessEvents.size).toBe(1);
1295
+ await vi.advanceTimersByTimeAsync(60_001);
1296
+ expect(handleProcessEvent.pendingRuntimeProcessEvents.size).toBe(0);
1297
+ await manager.destroyAll();
1298
+ }
1299
+ finally {
1300
+ vi.useRealTimers();
1301
+ }
1302
+ });
1303
+ it('persists bounded cleanup retry attempts for recovery scans', async () => {
1304
+ const { session } = await createSessionFixture();
1305
+ const manager = new SessionManager(new EventBus());
1306
+ const processRecord = await prisma.executionProcess.create({
1307
+ data: {
1308
+ sessionId: session.id,
1309
+ launchClaimNumber: 1,
1310
+ runtimeInstanceId: 'runtime-retry',
1311
+ pid: 4201,
1312
+ processGroupId: '4201',
1313
+ birthMarker: 'linux:200:owner-retry',
1314
+ ownershipToken: 'owner-retry',
1315
+ cleanupState: 'PENDING',
1316
+ },
1317
+ });
1318
+ const markCleanup = manager.markRuntimeProcessCleanupState.bind(manager);
1319
+ await markCleanup(session.id, 'runtime-retry', 'FAILED', 'first failure', 1);
1320
+ const first = await prisma.executionProcess.findUniqueOrThrow({ where: { id: processRecord.id } });
1321
+ await markCleanup(session.id, 'runtime-retry', 'FAILED', 'second failure', 1);
1322
+ const second = await prisma.executionProcess.findUniqueOrThrow({ where: { id: processRecord.id } });
1323
+ expect(first).toMatchObject({ cleanupState: 'FAILED', cleanupAttemptCount: 1, cleanupError: 'first failure' });
1324
+ expect(second).toMatchObject({ cleanupState: 'FAILED', cleanupAttemptCount: 2, cleanupError: 'second failure' });
1325
+ expect(first.nextCleanupRetryAt).not.toBeNull();
1326
+ expect(second.nextCleanupRetryAt.getTime()).toBeGreaterThan(first.nextCleanupRetryAt.getTime());
1327
+ await manager.destroyAll();
1328
+ });
939
1329
  });
940
1330
  //# sourceMappingURL=session-manager.lifecycle.test.js.map