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
@@ -98,6 +98,13 @@ export class SessionManager {
98
98
  */
99
99
  followUpReservations = new Map();
100
100
  followUpReservationReleases = new Set();
101
+ /** Serializes user stop and direct follow-up actions for each Session. */
102
+ sessionActionReservations = new Map();
103
+ sessionActionReservationReleases = new Set();
104
+ /** Public initial starts are revocable before they enter RuntimeCoordinator admission. */
105
+ initialStartOperations = new Map();
106
+ /** A synchronous stop intent rejects starts registered while stop is still in flight. */
107
+ sessionStopIntentCounts = new Map();
101
108
  /** Incremented for every start/send cycle so late post-processing cannot affect a new turn. */
102
109
  sessionGenerations = new Map();
103
110
  // 每个 session 上次写入 TeamRun 心跳时间戳的时刻,用于节流 lastHeartbeatAt 落库。
@@ -114,12 +121,6 @@ export class SessionManager {
114
121
  /** Durable conversation turns are consumed one at a time per Session. */
115
122
  conversationQueueWorkers = new Map();
116
123
  conversationQueueStopping = false;
117
- /** Admission boundary covering the short initial-start -> process-owning handoff. */
118
- pendingInitialStarts = new Map();
119
- initialStartResolvers = new Map();
120
- /** Same boundary for follow-up/reply turns, which can race a direct stop. */
121
- pendingProcessStarts = new Map();
122
- processStartResolvers = new Map();
123
124
  artifactService = new AgentArtifactService();
124
125
  static SNAPSHOT_CHECKPOINT_MS = 15_000;
125
126
  static HEARTBEAT_THROTTLE_MS = 30_000;
@@ -145,8 +146,37 @@ export class SessionManager {
145
146
  onDriverSessionDisposed: (sessionId) => {
146
147
  revokeAgentApiCredential(sessionId);
147
148
  },
148
- onDriverSessionDisposedInstance: (sessionId, runtimeInstanceId, launchClaimNumber) => {
149
- return this.markRuntimeProcessCleanupState(sessionId, runtimeInstanceId, 'CONFIRMED', undefined, launchClaimNumber);
149
+ onDriverSessionDisposedInstance: async (sessionId, runtimeInstanceId, launchClaimNumber) => {
150
+ await this.markRuntimeProcessCleanupState(sessionId, runtimeInstanceId, 'CONFIRMED', undefined, launchClaimNumber);
151
+ const resolvedClaim = Number.isInteger(launchClaimNumber) && launchClaimNumber > 0
152
+ ? launchClaimNumber
153
+ : null;
154
+ const [process, launch] = resolvedClaim == null
155
+ ? [null, null]
156
+ : await Promise.all([
157
+ prisma.executionProcess.findFirst({
158
+ where: { sessionId, runtimeInstanceId, launchClaimNumber: resolvedClaim },
159
+ select: { cleanupState: true },
160
+ }),
161
+ prisma.session.findUnique({
162
+ where: { id: sessionId },
163
+ select: {
164
+ runtimeLaunchState: true,
165
+ runtimeLaunchClaimCount: true,
166
+ runtimeLaunchResolvedCount: true,
167
+ },
168
+ }),
169
+ ]);
170
+ const processConfirmed = process?.cleanupState === 'CONFIRMED'
171
+ && !this.hasRuntimeProcessOwner(runtimeInstanceId, sessionId, resolvedClaim);
172
+ const safelyNeverStarted = process == null
173
+ && launch?.runtimeLaunchState === RUNTIME_LAUNCH_STATES.SAFE_PRE_CHILD_FAILURE
174
+ && launch.runtimeLaunchClaimCount === resolvedClaim
175
+ && launch.runtimeLaunchResolvedCount === resolvedClaim;
176
+ if (!processConfirmed
177
+ && !safelyNeverStarted) {
178
+ throw new AgentRuntimeError('runtime_cleanup_pending', 'close', `Runtime process cleanup evidence for '${runtimeInstanceId}' is not durably confirmed`, true);
179
+ }
150
180
  },
151
181
  onDriverSessionDisposeFailed: (sessionId, runtimeInstanceId, error, launchClaimNumber) => {
152
182
  this.logSessionError('session.runtimeDispose', error, { sessionId, runtimeInstanceId });
@@ -299,7 +329,10 @@ export class SessionManager {
299
329
  }
300
330
  try {
301
331
  if (this.runtimeCoordinator.hasRuntimeInstance(input.sessionId, input.runtimeInstanceId)) {
302
- await this.runtimeCoordinator.disposeSession(input.sessionId, input.runtimeInstanceId);
332
+ const handled = await this.runtimeCoordinator.retryDisposedSessionCleanup(input.sessionId, input.runtimeInstanceId);
333
+ if (!handled && this.runtimeCoordinator.hasRuntimeInstance(input.sessionId, input.runtimeInstanceId)) {
334
+ await this.runtimeCoordinator.disposeSession(input.sessionId, input.runtimeInstanceId);
335
+ }
303
336
  return;
304
337
  }
305
338
  await cleanupPersistedAcpProcessTree(input);
@@ -404,15 +437,16 @@ export class SessionManager {
404
437
  }
405
438
  async start(id) {
406
439
  console.log('[SessionManager] 🚀 Starting session:', id);
407
- const session = await this.findSessionExecutionRecord(id);
408
- if (!session) {
409
- console.log('[SessionManager] ❌ Session not found:', id);
410
- return null;
411
- }
412
- const initialStart = this.reserveInitialStart(id);
440
+ const startOperation = this.reserveInitialStartOperation(id);
413
441
  try {
414
- await this.waitForPendingAutoCommit(id);
415
- this.beginSessionExecution(id);
442
+ const session = await startOperation.waitFor(() => this.findSessionExecutionRecord(id));
443
+ startOperation.throwIfStopped();
444
+ if (!session) {
445
+ console.log('[SessionManager] ❌ Session not found:', id);
446
+ return null;
447
+ }
448
+ await startOperation.waitFor(() => this.waitForPendingAutoCommit(id));
449
+ startOperation.throwIfStopped();
416
450
  this.ensureExecutionRecordIsLive(session);
417
451
  const workingDir = this.getExecutionWorkingDir(session);
418
452
  console.log('[SessionManager] Session details:', {
@@ -422,30 +456,36 @@ export class SessionManager {
422
456
  prompt: summarizeTextForLog(session.prompt),
423
457
  workingDir,
424
458
  });
459
+ // No await separates this final operation check from Coordinator lease
460
+ // publication inside startRuntimeTurn(). A later stop is then observed by
461
+ // the runtime admission signal instead of being lost between the gates.
462
+ startOperation.throwIfStopped();
425
463
  await this.startRuntimeTurn(session, session.prompt, session.externalSessionId, session.providerId, 'load', undefined, true);
426
464
  return session;
427
465
  }
428
466
  finally {
429
- initialStart.release();
467
+ startOperation.release();
430
468
  }
431
469
  }
432
470
  async startFollowUp(id, resumeFromSessionId) {
433
471
  console.log('[SessionManager] 🚀 Starting follow-up session:', id);
434
472
  console.log('[SessionManager] Resume from Tower session:', resumeFromSessionId);
435
- const session = await this.findSessionExecutionRecord(id);
436
- if (!session) {
437
- console.log('[SessionManager] ❌ Session not found:', id);
438
- return null;
439
- }
440
- const initialStart = this.reserveInitialStart(id);
473
+ const startOperation = this.reserveInitialStartOperation(id);
441
474
  try {
442
- await this.waitForPendingAutoCommit(id);
443
- this.beginSessionExecution(id);
475
+ const session = await startOperation.waitFor(() => this.findSessionExecutionRecord(id));
476
+ startOperation.throwIfStopped();
477
+ if (!session) {
478
+ console.log('[SessionManager] ❌ Session not found:', id);
479
+ return null;
480
+ }
481
+ await startOperation.waitFor(() => this.waitForPendingAutoCommit(id));
482
+ startOperation.throwIfStopped();
444
483
  this.ensureExecutionRecordIsLive(session);
445
- const resumeFromSession = await prisma.session.findUnique({
484
+ const resumeFromSession = await startOperation.waitFor(() => prisma.session.findUnique({
446
485
  where: { id: resumeFromSessionId },
447
486
  select: { logSnapshot: true, externalSessionId: true },
448
- });
487
+ }));
488
+ startOperation.throwIfStopped();
449
489
  const agentSessionId = resumeFromSession
450
490
  ? resumeFromSession.externalSessionId
451
491
  ?? this.resolveAgentSessionId(resumeFromSessionId, resumeFromSession.logSnapshot)
@@ -459,11 +499,12 @@ export class SessionManager {
459
499
  prompt: summarizeTextForLog(session.prompt),
460
500
  workingDir: this.getExecutionWorkingDir(session),
461
501
  });
502
+ startOperation.throwIfStopped();
462
503
  await this.startRuntimeTurn(session, session.prompt, agentSessionId, session.providerId, 'resume', undefined, true);
463
504
  return session;
464
505
  }
465
506
  finally {
466
- initialStart.release();
507
+ startOperation.release();
467
508
  }
468
509
  }
469
510
  async sendMessage(id, message, providerId, expectedTeamRunInvocationId, options = {}) {
@@ -473,16 +514,12 @@ export class SessionManager {
473
514
  console.log('[SessionManager] Switching provider to:', providerId);
474
515
  }
475
516
  const reservation = this.reserveFollowUp(id);
517
+ let actionReservation;
476
518
  let releaseTeamRunAdmission;
477
519
  try {
478
520
  // Serialize concurrent follow-ups for the same session while retaining
479
521
  // the reservation in the map so the old finalizer cannot reconcile.
480
522
  await reservation.previous;
481
- const session = await this.findSessionExecutionRecord(id);
482
- if (!session) {
483
- console.log('[SessionManager] ❌ Session not found:', id);
484
- return null;
485
- }
486
523
  // TeamRun follow-ups are admission-controlled just like initial starts.
487
524
  // A REST/MCP caller must carry the current invocation identity; browser
488
525
  // or stale/terminal callers must never be able to resurrect a member.
@@ -501,7 +538,21 @@ export class SessionManager {
501
538
  else if (expectedTeamRunInvocationId) {
502
539
  await this.assertTeamRunDispatchAdmitted(id, expectedTeamRunInvocationId);
503
540
  }
541
+ // TeamRun stop owns member admission before entering SessionManager.
542
+ // Preserve that lock order, then serialize the runtime mutation itself.
543
+ actionReservation = this.reserveSessionAction(id);
544
+ await actionReservation.previous;
545
+ const session = await this.findSessionExecutionRecord(id);
546
+ if (!session) {
547
+ console.log('[SessionManager] ❌ Session not found:', id);
548
+ return null;
549
+ }
504
550
  this.ensureExecutionRecordIsLive(session);
551
+ // A failed explicit stop leaves the DISPOSED DriverSession attached so
552
+ // its process owner can be retried. Confirm that cleanup before clearing
553
+ // the terminal gate, advancing the generation, issuing a credential, or
554
+ // claiming another launch; otherwise a never-opened turn is quarantined.
555
+ await this.runtimeCoordinator.retryDisposedSessionCleanup(id);
505
556
  const resumeMode = this.normalizeRuntimeType(session.runtimeType) === RuntimeType.ACP
506
557
  && hasCompletePersistedSnapshot(session)
507
558
  ? 'resume'
@@ -530,12 +581,6 @@ export class SessionManager {
530
581
  });
531
582
  console.log(`[SessionManager] ✅ Provider switched to: ${switchedProvider?.name ?? providerId}`);
532
583
  }
533
- // A rejected follow-up must not cancel the completed turn's post-exit
534
- // work. Advance the generation only after the session/provider checks and
535
- // any provider persistence have succeeded, immediately before replacing
536
- // the old execution with the new one.
537
- this.invalidateSessionGeneration(id);
538
- this.beginSessionExecution(id);
539
584
  // Stop the previous turn before waiting for its auto-commit boundary. The
540
585
  // coordinator suppresses that superseded turn's terminal event so it cannot
541
586
  // finalize the newly reserved generation.
@@ -556,30 +601,62 @@ export class SessionManager {
556
601
  if (providerId && providerId !== session.providerId) {
557
602
  await this.runtimeCoordinator.disposeSession(id);
558
603
  }
559
- const processStart = this.reserveProcessStart(id);
604
+ await this.startRuntimeTurn(session, message, agentSessionId, effectiveProviderId, resumeMode, userEntryId, false, expectedTeamRunInvocationId, true);
605
+ return session;
606
+ }
607
+ finally {
608
+ releaseTeamRunAdmission?.();
609
+ reservation.release();
610
+ actionReservation?.release();
611
+ }
612
+ }
613
+ async stop(id, options = {}) {
614
+ // Publish stop intent synchronously, before the session-action queue can
615
+ // yield. Initial starts still doing DB/auto-commit preparation are revoked
616
+ // without making stop wait for those unrelated operations.
617
+ const releaseStopIntent = this.beginSessionStopIntent(id);
618
+ const actionReservation = this.reserveSessionAction(id);
619
+ let pendingFinalization;
620
+ let stoppedSession = null;
621
+ try {
560
622
  try {
561
- await this.startRuntimeTurn(session, message, agentSessionId, effectiveProviderId, resumeMode, userEntryId, false, expectedTeamRunInvocationId);
562
- return session;
623
+ await actionReservation.previous;
624
+ const result = await this.stopWithinSessionAction(id, options);
625
+ stoppedSession = result.session;
626
+ pendingFinalization = result.pendingFinalization;
563
627
  }
564
628
  finally {
565
- processStart.release();
629
+ // A follow-up queued after stop may already be blocking the old terminal
630
+ // finalizer. Release the action boundary before waiting for that finalizer.
631
+ actionReservation.release();
566
632
  }
633
+ await pendingFinalization;
634
+ return stoppedSession;
567
635
  }
568
636
  finally {
569
- releaseTeamRunAdmission?.();
570
- reservation.release();
637
+ releaseStopIntent();
571
638
  }
572
639
  }
573
- async stop(id, options = {}) {
640
+ async stopWithinSessionAction(id, options) {
574
641
  const session = await prisma.session.findUnique({ where: { id } });
575
642
  if (!session)
576
- return null;
577
- // If initial start has already crossed into its short admission handoff,
578
- // let it establish the runtime first; this stop then owns and cleans it.
579
- // If stop wins before that handoff, the PENDING -> RUNNING CAS below fails
580
- // and no process-owning call can occur after stop returns.
581
- await this.pendingInitialStarts.get(id);
582
- await this.pendingProcessStarts.get(id);
643
+ return { session: null };
644
+ const hasActiveTurn = this.runtimeCoordinator.hasActiveTurn(id);
645
+ const runtimeDisposal = this.normalizeRuntimeType(session.runtimeType) === RuntimeType.ACP && hasActiveTurn
646
+ ? this.runtimeCoordinator.cancelAndDisposeSession(id)
647
+ : this.runtimeCoordinator.disposeSession(id);
648
+ // TeamRun reconciliation and snapshot persistence may run before cleanup is
649
+ // awaited. Observe an early rejection while retaining it for the boundary below.
650
+ void runtimeDisposal.catch(() => undefined);
651
+ if (hasActiveTurn && this.normalizeRuntimeType(session.runtimeType) !== RuntimeType.ACP) {
652
+ void this.runtimeCoordinator.cancelTurn(id).catch((error) => {
653
+ this.logSessionError('session.runtimeCancel', error, { sessionId: id });
654
+ });
655
+ }
656
+ // The disposal request above has cancelled any startup lease that can mint
657
+ // a DriverSession-bound credential. Revoke it before fallible persistence
658
+ // while the runtime owner remains available for a later cleanup retry.
659
+ revokeAgentApiCredential(id);
583
660
  // Revoke TeamRun dispatch before waiting for runtime cleanup. A direct
584
661
  // Session stop does not pass through TeamSchedulerService, so delaying this
585
662
  // until cleanup succeeds would leave heartbeat/follow-up admission open.
@@ -587,7 +664,6 @@ export class SessionManager {
587
664
  await this.teamReconciler.handleSessionStopped(id);
588
665
  }
589
666
  const terminalStatus = this.terminalSessions.get(id);
590
- const hasActiveTurn = this.runtimeCoordinator.hasActiveTurn(id);
591
667
  const persistedTerminal = [
592
668
  SessionStatus.COMPLETED,
593
669
  SessionStatus.FAILED,
@@ -599,44 +675,35 @@ export class SessionManager {
599
675
  // clean up the PTY, but it must not regress the persisted status. The
600
676
  // backing TeamRun invocation may still be waiting for a room reply, so
601
677
  // it must still pass through the cancellation reconciler.
602
- await this.runtimeCoordinator.disposeSession(id);
678
+ await runtimeDisposal;
603
679
  if (!await this.isRuntimeCleanupConfirmed(id)) {
604
680
  this.terminalSessions.delete(id);
605
- return session;
681
+ return { session };
606
682
  }
607
683
  this.maybeClearTerminalState(id);
608
- await pendingFinalization;
609
684
  if (terminalStatus && !persistedTerminal) {
610
685
  await prisma.session.updateMany({
611
686
  where: { id, status: { notIn: [SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.CANCELLED] } },
612
687
  data: { status: terminalStatus },
613
688
  });
614
689
  }
615
- revokeAgentApiCredential(id);
616
- return session;
690
+ return {
691
+ session,
692
+ pendingFinalization: options.skipTeamRunReconcile ? undefined : pendingFinalization,
693
+ };
617
694
  }
618
695
  this.terminalSessions.set(id, SessionStatus.CANCELLED);
619
- if (this.normalizeRuntimeType(session.runtimeType) === RuntimeType.ACP && hasActiveTurn) {
620
- await this.runtimeCoordinator.abandonTurn(id).catch((error) => {
621
- this.logSessionError('session.runtimeCancel', error, { sessionId: id });
622
- return false;
623
- });
624
- }
625
- else {
626
- await this.runtimeCoordinator.cancelTurn(id).catch((error) => {
627
- this.logSessionError('session.runtimeCancel', error, { sessionId: id });
628
- });
629
- }
696
+ await runtimeDisposal;
630
697
  // Explicit stop revokes the DriverSession-bound workspace-service credential.
631
698
  // A later follow-up reopens from the persisted external session id with a new credential.
632
699
  // Do not report a stopped TeamRun member until the owned process tree has
633
700
  // confirmed cleanup. The scheduler will retain the invocation and block
634
701
  // the next admission when this rejects, allowing a later retry/recovery.
635
- await this.runtimeCoordinator.disposeSession(id);
636
702
  if (!await this.isRuntimeCleanupConfirmed(id)) {
637
703
  this.terminalSessions.delete(id);
638
- return session;
704
+ return { session };
639
705
  }
706
+ await this.externalSessionPersistence.get(id);
640
707
  const msgStore = sessionMsgStoreManager.get(id);
641
708
  if (msgStore) {
642
709
  msgStore.pushFinished();
@@ -662,8 +729,7 @@ export class SessionManager {
662
729
  // store after the CANCELLED snapshot has been persisted above.
663
730
  sessionMsgStoreManager.delete(id);
664
731
  this.releaseSnapshotPersistenceState(id);
665
- revokeAgentApiCredential(id);
666
- return session;
732
+ return { session };
667
733
  }
668
734
  /** @deprecated Use hasActiveTurn(). */
669
735
  hasActivePipeline(sessionId) {
@@ -703,14 +769,6 @@ export class SessionManager {
703
769
  async destroyAll() {
704
770
  this.stopConversationQueue();
705
771
  await this.runtimeCoordinator.destroyAll();
706
- for (const resolve of this.initialStartResolvers.values())
707
- resolve();
708
- this.initialStartResolvers.clear();
709
- this.pendingInitialStarts.clear();
710
- for (const resolve of this.processStartResolvers.values())
711
- resolve();
712
- this.processStartResolvers.clear();
713
- this.pendingProcessStarts.clear();
714
772
  this.pendingRuntimeProcessEvents.clear();
715
773
  for (const timer of this.pendingRuntimeEventExpiryTimers.values())
716
774
  clearTimeout(timer);
@@ -726,6 +784,17 @@ export class SessionManager {
726
784
  release();
727
785
  this.followUpReservationReleases.clear();
728
786
  this.followUpReservations.clear();
787
+ for (const release of [...this.sessionActionReservationReleases])
788
+ release();
789
+ this.sessionActionReservationReleases.clear();
790
+ this.sessionActionReservations.clear();
791
+ for (const operations of this.initialStartOperations.values()) {
792
+ for (const controller of operations) {
793
+ controller.abort(this.sessionStartStoppedError());
794
+ }
795
+ }
796
+ this.initialStartOperations.clear();
797
+ this.sessionStopIntentCounts.clear();
729
798
  clearAgentApiCredentials();
730
799
  }
731
800
  kickConversationQueue(sessionId) {
@@ -868,80 +937,129 @@ export class SessionManager {
868
937
  }
869
938
  return null;
870
939
  }
871
- async startRuntimeTurn(session, prompt, resumeExternalSessionId, providerId = session.providerId, resumeMode = 'load', historyBoundaryEntryId, initialStart = false, expectedTeamRunInvocationId) {
940
+ async startRuntimeTurn(session, prompt, resumeExternalSessionId, providerId = session.providerId, resumeMode = 'load', historyBoundaryEntryId, initialStart = false, expectedTeamRunInvocationId, advanceGeneration = false) {
872
941
  const workingDir = this.getExecutionWorkingDir(session);
873
- const env = ExecutionEnv.default(workingDir);
874
- if (providerId) {
875
- const provider = getProviderById(providerId);
876
- if (provider && Object.keys(provider.env).length > 0) {
877
- env.merge(filterAgentSubprocessExternalEnv(provider.env));
878
- }
879
- }
880
- if (!this.isConversationSession(session)) {
881
- await this.injectTeamRunInvocationEnv(session.id, env);
882
- }
883
- this.injectAgentTowerMcpServiceEnv(session.id, env);
884
- const isNewStore = !sessionMsgStoreManager.has(session.id);
885
- const msgStore = sessionMsgStoreManager.getOrCreate(session.id);
886
- if (isNewStore && session.logSnapshot) {
887
- try {
888
- msgStore.restoreFromSnapshot(JSON.parse(session.logSnapshot));
889
- }
890
- catch (error) {
891
- this.logSessionError('session.snapshotRestore', error, { sessionId: session.id });
892
- }
893
- }
894
942
  let launchClaimNumber = null;
943
+ let admissionEntered = false;
944
+ let reportedError;
895
945
  try {
896
- // Every turn claim is durable before RuntimeCoordinator can call a driver.
897
- // Initial admission updates PENDING -> RUNNING and the claim counter in
898
- // one transaction; follow-ups use the same evidence without pretending
899
- // that an existing ACP transport necessarily spawns a new child.
900
- launchClaimNumber = await this.claimRuntimeLaunch(session.id, initialStart);
901
- try {
902
- await this.assertTeamRunDispatchAdmitted(session.id, expectedTeamRunInvocationId);
903
- }
904
- catch (error) {
905
- throw markPreChildProcessFailure(error);
906
- }
907
- const runtimePrompt = buildWorkspaceRuntimePrompt(session, prompt);
908
- const handle = await this.runtimeCoordinator.startTurn({
909
- towerSessionId: session.id,
910
- agentType: session.agentType,
911
- runtimeType: this.normalizeRuntimeType(session.runtimeType),
912
- variant: session.variant ?? 'DEFAULT',
913
- providerId,
914
- workingDir,
915
- env,
916
- externalSessionId: session.externalSessionId,
917
- msgStore,
918
- prompt: session.purpose === SessionPurpose.CHAT
919
- ? appendAgentOutputIntentInstructions(runtimePrompt)
920
- : runtimePrompt,
921
- resumeExternalSessionId,
922
- resumeMode,
923
- historyBoundaryEntryId,
924
- launchClaimNumber,
946
+ let handle;
947
+ await this.runtimeCoordinator.withStartAdmission(session.id, async (admission) => {
948
+ admissionEntered = true;
949
+ try {
950
+ this.assertPreChildAdmission(admission);
951
+ // A rejected follow-up must not supersede the completed generation
952
+ // until it owns the same disposal boundary as credential/claim setup.
953
+ if (advanceGeneration)
954
+ this.invalidateSessionGeneration(session.id);
955
+ this.beginSessionExecution(session.id);
956
+ const env = ExecutionEnv.default(workingDir);
957
+ if (providerId) {
958
+ const provider = getProviderById(providerId);
959
+ if (provider && Object.keys(provider.env).length > 0) {
960
+ env.merge(filterAgentSubprocessExternalEnv(provider.env));
961
+ }
962
+ }
963
+ if (!this.isConversationSession(session)) {
964
+ await this.injectTeamRunInvocationEnv(session.id, env);
965
+ this.assertPreChildAdmission(admission);
966
+ }
967
+ this.injectAgentTowerMcpServiceEnv(session.id, env);
968
+ this.assertPreChildAdmission(admission);
969
+ const isNewStore = !sessionMsgStoreManager.has(session.id);
970
+ const msgStore = sessionMsgStoreManager.getOrCreate(session.id);
971
+ if (isNewStore && session.logSnapshot) {
972
+ try {
973
+ msgStore.restoreFromSnapshot(JSON.parse(session.logSnapshot));
974
+ }
975
+ catch (error) {
976
+ this.logSessionError('session.snapshotRestore', error, { sessionId: session.id });
977
+ }
978
+ }
979
+ // Every turn claim is durable before RuntimeCoordinator can call a driver.
980
+ // The lease remains held until runTurn has either failed before spawn or
981
+ // handed the child/transport to its DriverSession owner.
982
+ launchClaimNumber = await this.claimRuntimeLaunch(session.id, initialStart);
983
+ this.assertPreChildAdmission(admission);
984
+ try {
985
+ await this.assertTeamRunDispatchAdmitted(session.id, expectedTeamRunInvocationId);
986
+ }
987
+ catch (error) {
988
+ throw markPreChildProcessFailure(error);
989
+ }
990
+ this.assertPreChildAdmission(admission);
991
+ const runtimePrompt = buildWorkspaceRuntimePrompt(session, prompt);
992
+ handle = await this.runtimeCoordinator.startTurn({
993
+ towerSessionId: session.id,
994
+ agentType: session.agentType,
995
+ runtimeType: this.normalizeRuntimeType(session.runtimeType),
996
+ variant: session.variant ?? 'DEFAULT',
997
+ providerId,
998
+ workingDir,
999
+ env,
1000
+ externalSessionId: session.externalSessionId,
1001
+ msgStore,
1002
+ prompt: session.purpose === SessionPurpose.CHAT
1003
+ ? appendAgentOutputIntentInstructions(runtimePrompt)
1004
+ : runtimePrompt,
1005
+ resumeExternalSessionId,
1006
+ resumeMode,
1007
+ historyBoundaryEntryId,
1008
+ launchClaimNumber,
1009
+ admission,
1010
+ });
1011
+ await this.resolveReusedRuntimeLaunch(session.id, launchClaimNumber);
1012
+ this.eventBus.emit('session:started', { sessionId: session.id });
1013
+ }
1014
+ catch (error) {
1015
+ const preChildFailure = launchClaimNumber != null && (isPreChildProcessFailure(error)
1016
+ || (error instanceof AgentRuntimeError && error.code === 'runtime_admission_cancelled'));
1017
+ let launchResolutionError;
1018
+ if (preChildFailure) {
1019
+ try {
1020
+ // Finish the durable no-child proof before disposal may close or
1021
+ // delete the managed session attached to this lease.
1022
+ await this.resolvePreChildRuntimeLaunchFailure(session.id, launchClaimNumber, error);
1023
+ }
1024
+ catch (persistError) {
1025
+ launchResolutionError = persistError;
1026
+ }
1027
+ }
1028
+ revokeAgentApiCredential(session.id);
1029
+ if (launchClaimNumber != null) {
1030
+ if (!preChildFailure) {
1031
+ await this.quarantineRuntimeLaunch(session.id, launchClaimNumber, error);
1032
+ }
1033
+ else if (launchResolutionError) {
1034
+ await this.quarantineRuntimeLaunch(session.id, launchClaimNumber, launchResolutionError);
1035
+ }
1036
+ }
1037
+ reportedError = launchResolutionError ?? error;
1038
+ throw reportedError;
1039
+ }
925
1040
  });
926
- await this.resolveReusedRuntimeLaunch(session.id, launchClaimNumber);
927
1041
  // Terminal persistence is driven by Runtime turn events. Attach a catch
928
1042
  // so the public start/message methods do not leave a rejected handle
929
1043
  // unobserved after they have returned to the HTTP caller.
930
1044
  void handle.completion.catch(() => undefined);
931
- this.eventBus.emit('session:started', { sessionId: session.id });
932
1045
  await this.checkTaskAutoRevert(session.id);
933
1046
  }
934
1047
  catch (error) {
1048
+ // Admission may fail while waiting for an older disposal. Do not turn
1049
+ // that retryable gate failure into a fresh disposal attempt.
1050
+ const finalError = reportedError ?? error;
1051
+ if (!admissionEntered) {
1052
+ this.logSessionError('session.runtimeAdmission', finalError, {
1053
+ sessionId: session.id,
1054
+ agentType: session.agentType,
1055
+ runtimeType: session.runtimeType,
1056
+ providerId,
1057
+ workingDir,
1058
+ });
1059
+ throw normalizeExecutorStartError(finalError);
1060
+ }
935
1061
  await this.runtimeCoordinator.disposeSession(session.id).catch(() => undefined);
936
1062
  revokeAgentApiCredential(session.id);
937
- if (launchClaimNumber != null) {
938
- if (isPreChildProcessFailure(error)) {
939
- await this.resolvePreChildRuntimeLaunchFailure(session.id, launchClaimNumber, error);
940
- }
941
- else {
942
- await this.quarantineRuntimeLaunch(session.id, launchClaimNumber, error);
943
- }
944
- }
945
1063
  const cleanupConfirmed = await this.isRuntimeCleanupConfirmed(session.id).catch(() => false);
946
1064
  if (cleanupConfirmed) {
947
1065
  await prisma.session.update({
@@ -951,14 +1069,22 @@ export class SessionManager {
951
1069
  }
952
1070
  sessionMsgStoreManager.delete(session.id);
953
1071
  this.releaseSnapshotPersistenceState(session.id);
954
- this.logSessionError('session.runtimeStart', error, {
1072
+ this.logSessionError('session.runtimeStart', finalError, {
955
1073
  sessionId: session.id,
956
1074
  agentType: session.agentType,
957
1075
  runtimeType: session.runtimeType,
958
1076
  providerId,
959
1077
  workingDir,
960
1078
  });
961
- throw normalizeExecutorStartError(error);
1079
+ throw normalizeExecutorStartError(finalError);
1080
+ }
1081
+ }
1082
+ assertPreChildAdmission(admission) {
1083
+ try {
1084
+ admission.throwIfCancelled();
1085
+ }
1086
+ catch (error) {
1087
+ throw markPreChildProcessFailure(error);
962
1088
  }
963
1089
  }
964
1090
  async claimRuntimeLaunch(sessionId, initialStart) {
@@ -1054,36 +1180,6 @@ export class SessionManager {
1054
1180
  });
1055
1181
  console.warn(`[SessionManager] Session ${sessionId} runtime launch quarantined: ${diagnostic}`);
1056
1182
  }
1057
- reserveInitialStart(sessionId) {
1058
- let resolve;
1059
- const promise = new Promise((done) => { resolve = done; });
1060
- this.pendingInitialStarts.set(sessionId, promise);
1061
- this.initialStartResolvers.set(sessionId, resolve);
1062
- return {
1063
- release: () => {
1064
- if (this.pendingInitialStarts.get(sessionId) !== promise)
1065
- return;
1066
- this.pendingInitialStarts.delete(sessionId);
1067
- this.initialStartResolvers.delete(sessionId);
1068
- resolve();
1069
- },
1070
- };
1071
- }
1072
- reserveProcessStart(sessionId) {
1073
- let resolve;
1074
- const promise = new Promise((done) => { resolve = done; });
1075
- this.pendingProcessStarts.set(sessionId, promise);
1076
- this.processStartResolvers.set(sessionId, resolve);
1077
- return {
1078
- release: () => {
1079
- if (this.pendingProcessStarts.get(sessionId) !== promise)
1080
- return;
1081
- this.pendingProcessStarts.delete(sessionId);
1082
- this.processStartResolvers.delete(sessionId);
1083
- resolve();
1084
- },
1085
- };
1086
- }
1087
1183
  async assertTeamRunDispatchAdmitted(sessionId, expectedInvocationId) {
1088
1184
  const invocation = await prisma.agentInvocation.findFirst({
1089
1185
  where: {
@@ -2230,6 +2326,93 @@ export class SessionManager {
2230
2326
  this.followUpReservationReleases.add(release);
2231
2327
  return { previous, release };
2232
2328
  }
2329
+ reserveSessionAction(sessionId) {
2330
+ const previous = this.sessionActionReservations.get(sessionId) ?? Promise.resolve();
2331
+ let resolveReservation;
2332
+ const reservation = new Promise((resolve) => {
2333
+ resolveReservation = resolve;
2334
+ });
2335
+ let released = false;
2336
+ const release = () => {
2337
+ if (released)
2338
+ return;
2339
+ released = true;
2340
+ resolveReservation();
2341
+ this.sessionActionReservationReleases.delete(release);
2342
+ if (this.sessionActionReservations.get(sessionId) === reservation) {
2343
+ this.sessionActionReservations.delete(sessionId);
2344
+ }
2345
+ };
2346
+ this.sessionActionReservations.set(sessionId, reservation);
2347
+ this.sessionActionReservationReleases.add(release);
2348
+ return { previous, release };
2349
+ }
2350
+ reserveInitialStartOperation(sessionId) {
2351
+ const controller = new AbortController();
2352
+ const operations = this.initialStartOperations.get(sessionId) ?? new Set();
2353
+ operations.add(controller);
2354
+ this.initialStartOperations.set(sessionId, operations);
2355
+ if ((this.sessionStopIntentCounts.get(sessionId) ?? 0) > 0) {
2356
+ controller.abort(this.sessionStartStoppedError());
2357
+ }
2358
+ let released = false;
2359
+ return {
2360
+ waitFor: (operation) => this.waitForInitialStartOperation(operation, controller.signal),
2361
+ throwIfStopped: () => controller.signal.throwIfAborted(),
2362
+ release: () => {
2363
+ if (released)
2364
+ return;
2365
+ released = true;
2366
+ operations.delete(controller);
2367
+ if (operations.size === 0 && this.initialStartOperations.get(sessionId) === operations) {
2368
+ this.initialStartOperations.delete(sessionId);
2369
+ }
2370
+ },
2371
+ };
2372
+ }
2373
+ async waitForInitialStartOperation(operation, signal) {
2374
+ signal.throwIfAborted();
2375
+ let rejectStopped;
2376
+ const stopped = new Promise((_resolve, reject) => {
2377
+ rejectStopped = reject;
2378
+ });
2379
+ const onStop = () => rejectStopped(signal.reason ?? this.sessionStartStoppedError());
2380
+ signal.addEventListener('abort', onStop, { once: true });
2381
+ try {
2382
+ // Attach the race before starting fallible work. The second signal check
2383
+ // prevents a stop published during setup from launching the operation,
2384
+ // while the race continues to observe any operation that already started.
2385
+ const pending = Promise.resolve().then(() => {
2386
+ signal.throwIfAborted();
2387
+ return operation();
2388
+ });
2389
+ return await Promise.race([pending, stopped]);
2390
+ }
2391
+ finally {
2392
+ signal.removeEventListener('abort', onStop);
2393
+ }
2394
+ }
2395
+ beginSessionStopIntent(sessionId) {
2396
+ this.sessionStopIntentCounts.set(sessionId, (this.sessionStopIntentCounts.get(sessionId) ?? 0) + 1);
2397
+ const error = this.sessionStartStoppedError();
2398
+ for (const controller of this.initialStartOperations.get(sessionId) ?? []) {
2399
+ controller.abort(error);
2400
+ }
2401
+ let released = false;
2402
+ return () => {
2403
+ if (released)
2404
+ return;
2405
+ released = true;
2406
+ const remaining = (this.sessionStopIntentCounts.get(sessionId) ?? 1) - 1;
2407
+ if (remaining > 0)
2408
+ this.sessionStopIntentCounts.set(sessionId, remaining);
2409
+ else
2410
+ this.sessionStopIntentCounts.delete(sessionId);
2411
+ };
2412
+ }
2413
+ sessionStartStoppedError() {
2414
+ return new ServiceError('Session start was stopped before runtime admission', 'SESSION_NOT_ADMITTED', 409);
2415
+ }
2233
2416
  async waitForFollowUpReservation(sessionId) {
2234
2417
  // Follow-ups can queue behind one another. Re-check after each reservation
2235
2418
  // resolves so a finalizer never observes only an earlier queue item.