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
@@ -1,24 +1,39 @@
1
1
  import { prisma } from '../utils/index.js';
2
2
  import { SessionStatus, SessionPurpose, TaskStatus, SessionContext } from '../types/index.js';
3
- import { getProviderById, ExecutionEnv, normalizeExecutorStartError, } from '../executors/index.js';
3
+ import { getProviderById, ExecutionEnv, isPreChildProcessFailure, markPreChildProcessFailure, normalizeExecutorStartError, } from '../executors/index.js';
4
4
  import { filterAgentSubprocessExternalEnv } from '../executors/execution-env.js';
5
5
  import { sessionMsgStoreManager, createUserMessage, addNormalizedEntry, } from '../output/index.js';
6
6
  import { execGit } from '../git/git-cli.js';
7
7
  import { getCommitMessageService } from '../core/container.js';
8
8
  import { TeamReconcilerService } from './team-reconciler.service.js';
9
- import { NotFoundError, ValidationError } from '../errors.js';
9
+ import { acquireTeamMemberAdmission } from './team-member-admission-barrier.js';
10
+ import { NotFoundError, ServiceError, ValidationError } from '../errors.js';
11
+ import { AgentRuntimeError } from '../runtime/errors.js';
10
12
  import { ensureTaskNotDeleted } from './deleted-task-guard.js';
11
13
  import { getWorkspaceWorkingDir, isMainDirectoryWorkspace, } from './workspace-kind.js';
12
14
  import { writeErrorLog } from '../utils/error-log.js';
13
15
  import { AGENT_API_CREDENTIAL_ENV, clearAgentApiCredentials, createAgentApiCredential, revokeAgentApiCredential, } from '../utils/agent-api-credential.js';
14
- import { createHash } from 'node:crypto';
16
+ import { createHash, randomUUID } from 'node:crypto';
15
17
  import { RuntimeType, supportsAgentRuntime } from '@agent-tower/shared';
16
18
  import { getProviderRuntimeType } from '../executors/providers.js';
17
19
  import { appendAgentOutputIntentInstructions } from '../prompts/agent-output-intents.js';
18
20
  import { AgentArtifactService } from './agent-artifact.service.js';
19
21
  import { CliRuntimeDriver, AcpRuntimeDriver, RuntimeCoordinator, StaticRuntimeRegistry, setRuntimeStateSnapshot, } from '../runtime/index.js';
20
22
  import { buildWorkspaceRuntimePrompt } from '../prompts/workspace-background-service-policy.js';
23
+ import { cleanupPersistedAcpProcessTree, } from '../runtime/acp/process-manager.js';
24
+ import { evaluateSessionRuntimeCleanup, RUNTIME_LAUNCH_STATES, } from './session-runtime-cleanup-gate.js';
21
25
  const DEBUG_SNAPSHOT = process.env.DEBUG_SNAPSHOT === 'true';
26
+ const PROCESS_CLEANUP_RETRY_DELAYS_MS = [1_000, 5_000, 30_000, 120_000, 300_000];
27
+ const PENDING_RUNTIME_EVENT_TTL_MS = 60_000;
28
+ const PENDING_RUNTIME_EVENTS_PER_KEY = 8;
29
+ const PENDING_RUNTIME_EVENTS_GLOBAL = 128;
30
+ const CONVERSATION_TURN_QUEUED = 'QUEUED';
31
+ const CONVERSATION_TURN_RUNNING = 'RUNNING';
32
+ const CONVERSATION_TURN_COMPLETED = 'COMPLETED';
33
+ const CONVERSATION_TURN_FAILED = 'FAILED';
34
+ function eventKey(sessionId, runtimeInstanceId, launchClaimNumber) {
35
+ return `${sessionId}:${runtimeInstanceId}:${launchClaimNumber}`;
36
+ }
22
37
  function hashForLog(value) {
23
38
  return createHash('sha256').update(value).digest('hex').slice(0, 12);
24
39
  }
@@ -89,9 +104,22 @@ export class SessionManager {
89
104
  heartbeatThrottle = new Map();
90
105
  teamReconciler;
91
106
  runtimeCoordinator;
107
+ /** In-memory process owners are generation-scoped, never runtime-id global. */
92
108
  runtimeProcessIds = new Map();
109
+ /** Early exit/tree completion events can arrive before started-row persistence. */
110
+ pendingRuntimeProcessEvents = new Map();
111
+ pendingRuntimeEventExpiryTimers = new Map();
93
112
  runtimePermissionStates = new Map();
94
113
  externalSessionPersistence = new Map();
114
+ /** Durable conversation turns are consumed one at a time per Session. */
115
+ conversationQueueWorkers = new Map();
116
+ 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();
95
123
  artifactService = new AgentArtifactService();
96
124
  static SNAPSHOT_CHECKPOINT_MS = 15_000;
97
125
  static HEARTBEAT_THROTTLE_MS = 30_000;
@@ -111,7 +139,19 @@ export class SessionManager {
111
139
  onTurnEvent: (event) => this.handleRuntimeTurnEvent(event),
112
140
  onRuntimeState: (state) => this.handleRuntimeState(state),
113
141
  onProcessEvent: (event) => this.handleRuntimeProcessEvent(event),
114
- onDriverSessionDisposed: (sessionId) => revokeAgentApiCredential(sessionId),
142
+ onDriverSessionDisposeStarted: (sessionId, runtimeInstanceId, launchClaimNumber) => {
143
+ return this.markRuntimeProcessCleanupPending(sessionId, runtimeInstanceId, launchClaimNumber);
144
+ },
145
+ onDriverSessionDisposed: (sessionId) => {
146
+ revokeAgentApiCredential(sessionId);
147
+ },
148
+ onDriverSessionDisposedInstance: (sessionId, runtimeInstanceId, launchClaimNumber) => {
149
+ return this.markRuntimeProcessCleanupState(sessionId, runtimeInstanceId, 'CONFIRMED', undefined, launchClaimNumber);
150
+ },
151
+ onDriverSessionDisposeFailed: (sessionId, runtimeInstanceId, error, launchClaimNumber) => {
152
+ this.logSessionError('session.runtimeDispose', error, { sessionId, runtimeInstanceId });
153
+ return this.markRuntimeProcessCleanupState(sessionId, runtimeInstanceId, 'FAILED', error instanceof Error ? error.message : String(error), launchClaimNumber);
154
+ },
115
155
  });
116
156
  // Patches only mark the snapshot dirty. A low-frequency checkpoint keeps the
117
157
  // hot stream away from SQLite while terminal paths still force a final flush.
@@ -132,7 +172,10 @@ export class SessionManager {
132
172
  // The parser has already written raw stdout, the final assistant entry,
133
173
  // usage and all other state from the turn.completed chunk at this point.
134
174
  this.terminalSessions.set(sessionId, SessionStatus.COMPLETED);
135
- this.startSessionFinalization(sessionId, 0, { logicalCompletion: true });
175
+ this.startSessionFinalization(sessionId, 0, {
176
+ logicalCompletion: true,
177
+ runtimeInstanceId: this.runtimeCoordinator.getRuntimeInstanceId(sessionId),
178
+ });
136
179
  });
137
180
  this.eventBus.on('session:turn-failed', ({ sessionId }) => {
138
181
  if (this.terminalSessions.has(sessionId))
@@ -141,7 +184,10 @@ export class SessionManager {
141
184
  // without an exit code. Use a synthetic non-zero code for the shared
142
185
  // finalization path so success-only post-processing cannot run.
143
186
  this.terminalSessions.set(sessionId, SessionStatus.FAILED);
144
- this.startSessionFinalization(sessionId, 1, { logicalCompletion: true });
187
+ this.startSessionFinalization(sessionId, 1, {
188
+ logicalCompletion: true,
189
+ runtimeInstanceId: this.runtimeCoordinator.getRuntimeInstanceId(sessionId),
190
+ });
145
191
  });
146
192
  // NOTE: checkTaskAutoRevert is called directly (awaited) inside start()
147
193
  // and sendMessage() to guarantee the task status is updated before the
@@ -158,6 +204,168 @@ export class SessionManager {
158
204
  getRuntimeState(sessionId, runtimeType = RuntimeType.CLI) {
159
205
  return this.runtimeCoordinator.getState(sessionId, runtimeType);
160
206
  }
207
+ /**
208
+ * Persist a conversation message and schedule its runtime turn. The caller
209
+ * only waits for the SQLite insert; ACP startup and prompt delivery happen
210
+ * in the per-session queue worker.
211
+ */
212
+ async enqueueConversationMessage(sessionId, message, providerId) {
213
+ const enqueueStartedAt = Date.now();
214
+ const session = await this.findSessionExecutionRecord(sessionId);
215
+ if (!session) {
216
+ throw new NotFoundError('Session', sessionId);
217
+ }
218
+ this.ensureExecutionRecordIsLive(session);
219
+ if (!this.isConversationSession(session)) {
220
+ throw new ValidationError('Only conversation sessions support queued messages');
221
+ }
222
+ const effectiveProviderId = providerId ?? session.providerId;
223
+ if (effectiveProviderId) {
224
+ const provider = getProviderById(effectiveProviderId);
225
+ if (!provider) {
226
+ throw new ValidationError(`Provider not found: ${effectiveProviderId}`);
227
+ }
228
+ if (String(provider.agentType) !== session.agentType) {
229
+ throw new ValidationError(`Cannot switch provider: agentType mismatch. Session uses '${session.agentType}', but provider '${provider.name}' is for '${provider.agentType}'`);
230
+ }
231
+ if (getProviderRuntimeType(provider) !== this.normalizeRuntimeType(session.runtimeType)) {
232
+ throw new ValidationError(`Cannot switch provider: runtimeType mismatch. Session uses '${session.runtimeType}', but provider '${provider.name}' uses '${getProviderRuntimeType(provider)}'`);
233
+ }
234
+ }
235
+ // Generate both identifiers before the insert. The entry id is persisted
236
+ // with the queue row so a crash between applying the in-memory patch and
237
+ // updating the row remains replayable without creating a duplicate entry.
238
+ const turnId = randomUUID();
239
+ const userEntryId = `user:${turnId}`;
240
+ const turn = await prisma.conversationTurn.create({
241
+ data: {
242
+ id: turnId,
243
+ sessionId,
244
+ message,
245
+ userEntryId,
246
+ providerId: providerId ?? null,
247
+ status: CONVERSATION_TURN_QUEUED,
248
+ },
249
+ select: { id: true },
250
+ });
251
+ // Reflect the user message immediately. This is local, synchronous work;
252
+ // the expensive ACP startup/prompt remains exclusively in the worker. If
253
+ // the in-memory snapshot cannot be updated, leave the durable turn queued
254
+ // so the worker can retry the append instead of losing the message.
255
+ try {
256
+ this.appendUserMessageEntry(session, message, userEntryId);
257
+ }
258
+ catch (error) {
259
+ this.logSessionError('conversation.queueUserEntry', error, {
260
+ sessionId,
261
+ turnId: turn.id,
262
+ });
263
+ }
264
+ this.kickConversationQueue(sessionId);
265
+ console.log(`[ConversationQueue] enqueued session=${sessionId} turn=${turn.id} persistMs=${Date.now() - enqueueStartedAt}`);
266
+ return { turnId: turn.id };
267
+ }
268
+ /** Recover turns left in RUNNING state by an interrupted server process. */
269
+ async startConversationQueue() {
270
+ this.conversationQueueStopping = false;
271
+ await prisma.conversationTurn.updateMany({
272
+ where: { status: CONVERSATION_TURN_RUNNING },
273
+ data: {
274
+ status: CONVERSATION_TURN_QUEUED,
275
+ startedAt: null,
276
+ },
277
+ });
278
+ const pending = await prisma.conversationTurn.findMany({
279
+ where: { status: CONVERSATION_TURN_QUEUED },
280
+ select: { sessionId: true },
281
+ distinct: ['sessionId'],
282
+ });
283
+ for (const turn of pending) {
284
+ this.kickConversationQueue(turn.sessionId);
285
+ }
286
+ }
287
+ /** Stop scheduling new work during the Fastify shutdown phase. */
288
+ stopConversationQueue() {
289
+ this.conversationQueueStopping = true;
290
+ }
291
+ /** Dispose a TeamRun-owned runtime after its invocation reaches a terminal state. */
292
+ async disposeRuntimeSession(sessionId, expectedRuntimeInstanceId) {
293
+ await this.runtimeCoordinator.disposeSession(sessionId, expectedRuntimeInstanceId);
294
+ }
295
+ async retryRuntimeProcessCleanup(input) {
296
+ if (!Number.isInteger(input.launchClaimNumber) || input.launchClaimNumber <= 0) {
297
+ await this.quarantinePersistedRuntimeProcess(input.sessionId, input.runtimeInstanceId, 'Runtime cleanup evidence is missing launchClaimNumber; automated signalling is disabled', null);
298
+ throw new AgentRuntimeError('process_identity_mismatch', 'close', 'Runtime cleanup evidence is missing launchClaimNumber', true);
299
+ }
300
+ try {
301
+ if (this.runtimeCoordinator.hasRuntimeInstance(input.sessionId, input.runtimeInstanceId)) {
302
+ await this.runtimeCoordinator.disposeSession(input.sessionId, input.runtimeInstanceId);
303
+ return;
304
+ }
305
+ await cleanupPersistedAcpProcessTree(input);
306
+ await this.markRuntimeProcessCleanupState(input.sessionId, input.runtimeInstanceId, 'CONFIRMED', undefined, input.launchClaimNumber);
307
+ }
308
+ catch (error) {
309
+ await this.markRuntimeProcessCleanupState(input.sessionId, input.runtimeInstanceId, 'FAILED', error instanceof Error ? error.message : String(error), input.launchClaimNumber);
310
+ throw error;
311
+ }
312
+ }
313
+ hasRuntimeProcessOwner(runtimeInstanceId, sessionId, launchClaimNumber) {
314
+ if (sessionId && Number.isInteger(launchClaimNumber) && launchClaimNumber > 0) {
315
+ return this.runtimeProcessIds.has(eventKey(sessionId, runtimeInstanceId, launchClaimNumber));
316
+ }
317
+ return [...this.runtimeProcessIds.keys()].some((key) => key.split(':')[1] === runtimeInstanceId);
318
+ }
319
+ async quarantinePersistedRuntimeProcess(sessionId, runtimeInstanceId, diagnostic, launchClaimNumber) {
320
+ if (!Number.isInteger(launchClaimNumber) || launchClaimNumber <= 0) {
321
+ // Without a generation claim there is no safe process-row target. Keep
322
+ // the session blocked, but never mutate another launch's owner row.
323
+ await prisma.session.updateMany({
324
+ where: { id: sessionId },
325
+ data: {
326
+ runtimeLaunchState: RUNTIME_LAUNCH_STATES.QUARANTINED,
327
+ runtimeLaunchDiagnostic: diagnostic.slice(0, 2_000),
328
+ runtimeLaunchDiagnosticCount: { increment: 1 },
329
+ runtimeLaunchNextDiagnosticAt: new Date(Date.now() + 5 * 60_000),
330
+ },
331
+ }).catch((error) => {
332
+ this.logSessionError('session.processCleanupQuarantine', error, { sessionId, runtimeInstanceId });
333
+ });
334
+ return;
335
+ }
336
+ await prisma.executionProcess.updateMany({
337
+ where: {
338
+ sessionId,
339
+ runtimeInstanceId,
340
+ launchClaimNumber,
341
+ cleanupState: { not: 'CONFIRMED' },
342
+ },
343
+ data: {
344
+ cleanupState: 'QUARANTINED',
345
+ cleanupError: diagnostic.slice(0, 2_000),
346
+ cleanupAttemptCount: { increment: 1 },
347
+ nextCleanupRetryAt: new Date(Date.now() + 5 * 60_000),
348
+ },
349
+ }).catch((error) => {
350
+ this.logSessionError('session.processCleanupQuarantine', error, { sessionId, runtimeInstanceId });
351
+ });
352
+ }
353
+ runtimeProcessKeyFor(sessionId, runtimeInstanceId) {
354
+ const prefix = sessionId + ':' + runtimeInstanceId + ':';
355
+ return [...this.runtimeProcessIds.keys()].find((key) => key.startsWith(prefix));
356
+ }
357
+ launchClaimFromRuntimeProcessKey(key) {
358
+ if (!key)
359
+ return undefined;
360
+ const claim = Number(key.slice(key.lastIndexOf(':') + 1));
361
+ return Number.isInteger(claim) && claim > 0 ? claim : undefined;
362
+ }
363
+ async isRuntimeCleanupConfirmed(sessionId) {
364
+ return (await evaluateSessionRuntimeCleanup(sessionId, {
365
+ hasActiveTurn: (candidateId) => this.hasActiveTurn(candidateId),
366
+ hasRuntimeProcessOwner: (runtimeInstanceId, ownerSessionId, launchClaimNumber) => this.hasRuntimeProcessOwner(runtimeInstanceId, ownerSessionId ?? sessionId, launchClaimNumber),
367
+ })).confirmed;
368
+ }
161
369
  async resolveRuntimePermission(sessionId, requestId, optionId) {
162
370
  await this.runtimeCoordinator.resolvePermission(sessionId, requestId, optionId);
163
371
  }
@@ -201,19 +409,25 @@ export class SessionManager {
201
409
  console.log('[SessionManager] ❌ Session not found:', id);
202
410
  return null;
203
411
  }
204
- await this.waitForPendingAutoCommit(id);
205
- this.beginSessionExecution(id);
206
- this.ensureExecutionRecordIsLive(session);
207
- const workingDir = this.getExecutionWorkingDir(session);
208
- console.log('[SessionManager] Session details:', {
209
- id: session.id,
210
- agentType: session.agentType,
211
- variant: session.variant,
212
- prompt: summarizeTextForLog(session.prompt),
213
- workingDir,
214
- });
215
- await this.startRuntimeTurn(session, session.prompt, session.externalSessionId);
216
- return session;
412
+ const initialStart = this.reserveInitialStart(id);
413
+ try {
414
+ await this.waitForPendingAutoCommit(id);
415
+ this.beginSessionExecution(id);
416
+ this.ensureExecutionRecordIsLive(session);
417
+ const workingDir = this.getExecutionWorkingDir(session);
418
+ console.log('[SessionManager] Session details:', {
419
+ id: session.id,
420
+ agentType: session.agentType,
421
+ variant: session.variant,
422
+ prompt: summarizeTextForLog(session.prompt),
423
+ workingDir,
424
+ });
425
+ await this.startRuntimeTurn(session, session.prompt, session.externalSessionId, session.providerId, 'load', undefined, true);
426
+ return session;
427
+ }
428
+ finally {
429
+ initialStart.release();
430
+ }
217
431
  }
218
432
  async startFollowUp(id, resumeFromSessionId) {
219
433
  console.log('[SessionManager] 🚀 Starting follow-up session:', id);
@@ -223,36 +437,43 @@ export class SessionManager {
223
437
  console.log('[SessionManager] ❌ Session not found:', id);
224
438
  return null;
225
439
  }
226
- await this.waitForPendingAutoCommit(id);
227
- this.beginSessionExecution(id);
228
- this.ensureExecutionRecordIsLive(session);
229
- const resumeFromSession = await prisma.session.findUnique({
230
- where: { id: resumeFromSessionId },
231
- select: { logSnapshot: true, externalSessionId: true },
232
- });
233
- const agentSessionId = resumeFromSession
234
- ? resumeFromSession.externalSessionId
235
- ?? this.resolveAgentSessionId(resumeFromSessionId, resumeFromSession.logSnapshot)
236
- : null;
237
- console.log('[SessionManager] Follow-up session details:', {
238
- id: session.id,
239
- resumeFromSessionId,
240
- agentSessionId,
241
- agentType: session.agentType,
242
- variant: session.variant,
243
- prompt: summarizeTextForLog(session.prompt),
244
- workingDir: this.getExecutionWorkingDir(session),
245
- });
246
- await this.startRuntimeTurn(session, session.prompt, agentSessionId, session.providerId, 'resume');
247
- return session;
440
+ const initialStart = this.reserveInitialStart(id);
441
+ try {
442
+ await this.waitForPendingAutoCommit(id);
443
+ this.beginSessionExecution(id);
444
+ this.ensureExecutionRecordIsLive(session);
445
+ const resumeFromSession = await prisma.session.findUnique({
446
+ where: { id: resumeFromSessionId },
447
+ select: { logSnapshot: true, externalSessionId: true },
448
+ });
449
+ const agentSessionId = resumeFromSession
450
+ ? resumeFromSession.externalSessionId
451
+ ?? this.resolveAgentSessionId(resumeFromSessionId, resumeFromSession.logSnapshot)
452
+ : null;
453
+ console.log('[SessionManager] Follow-up session details:', {
454
+ id: session.id,
455
+ resumeFromSessionId,
456
+ agentSessionId,
457
+ agentType: session.agentType,
458
+ variant: session.variant,
459
+ prompt: summarizeTextForLog(session.prompt),
460
+ workingDir: this.getExecutionWorkingDir(session),
461
+ });
462
+ await this.startRuntimeTurn(session, session.prompt, agentSessionId, session.providerId, 'resume', undefined, true);
463
+ return session;
464
+ }
465
+ finally {
466
+ initialStart.release();
467
+ }
248
468
  }
249
- async sendMessage(id, message, providerId) {
469
+ async sendMessage(id, message, providerId, expectedTeamRunInvocationId, options = {}) {
250
470
  console.log('[SessionManager] 📨 Sending message to session:', id);
251
471
  console.log('[SessionManager] Message summary:', summarizeTextForLog(message));
252
472
  if (providerId) {
253
473
  console.log('[SessionManager] Switching provider to:', providerId);
254
474
  }
255
475
  const reservation = this.reserveFollowUp(id);
476
+ let releaseTeamRunAdmission;
256
477
  try {
257
478
  // Serialize concurrent follow-ups for the same session while retaining
258
479
  // the reservation in the map so the old finalizer cannot reconcile.
@@ -262,6 +483,24 @@ export class SessionManager {
262
483
  console.log('[SessionManager] ❌ Session not found:', id);
263
484
  return null;
264
485
  }
486
+ // TeamRun follow-ups are admission-controlled just like initial starts.
487
+ // A REST/MCP caller must carry the current invocation identity; browser
488
+ // or stale/terminal callers must never be able to resurrect a member.
489
+ const teamRunInvocation = await prisma.agentInvocation.findFirst({
490
+ where: { sessionId: id },
491
+ select: { id: true, teamRunId: true, memberId: true },
492
+ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
493
+ });
494
+ if (teamRunInvocation) {
495
+ if (expectedTeamRunInvocationId !== teamRunInvocation.id) {
496
+ throw new ServiceError('TeamRun follow-up requires the current invocation identity', 'SESSION_NOT_ADMITTED', 409);
497
+ }
498
+ releaseTeamRunAdmission = await acquireTeamMemberAdmission(teamRunInvocation.teamRunId, teamRunInvocation.memberId);
499
+ await this.assertTeamRunDispatchAdmitted(id, expectedTeamRunInvocationId);
500
+ }
501
+ else if (expectedTeamRunInvocationId) {
502
+ await this.assertTeamRunDispatchAdmitted(id, expectedTeamRunInvocationId);
503
+ }
265
504
  this.ensureExecutionRecordIsLive(session);
266
505
  const resumeMode = this.normalizeRuntimeType(session.runtimeType) === RuntimeType.ACP
267
506
  && hasCompletePersistedSnapshot(session)
@@ -311,49 +550,23 @@ export class SessionManager {
311
550
  }
312
551
  }
313
552
  await this.waitForPendingAutoCommit(id);
314
- const isNewStore = !sessionMsgStoreManager.has(id);
315
- const msgStore = sessionMsgStoreManager.getOrCreate(id);
316
- if (isNewStore && session.logSnapshot) {
317
- try {
318
- const snapshot = JSON.parse(session.logSnapshot);
319
- msgStore.restoreFromSnapshot(snapshot);
320
- }
321
- catch (error) {
322
- console.error(`[SessionManager] Failed to restore snapshot for session ${id}:`, error);
323
- }
324
- }
325
- // Heal index drift caused by previously failed patches (e.g. invalid value).
326
- // If entryIndex is ahead of snapshot length, subsequent add/replace paths
327
- // become out-of-bounds and all later patches fail.
328
- const preflightSnapshot = msgStore.getSnapshot();
329
- const expectedIndex = preflightSnapshot.entries.length;
330
- const currentIndex = msgStore.entryIndex.current();
331
- if (currentIndex !== expectedIndex) {
332
- if (DEBUG_SNAPSHOT) {
333
- console.warn(`[SessionManager:snapshot] rebase entryIndex sessionId=${id} currentIndex=${currentIndex} expectedIndex=${expectedIndex}`);
334
- }
335
- msgStore.entryIndex.startFrom(expectedIndex);
336
- }
337
- const userEntry = createUserMessage(message);
338
- const userIndex = msgStore.entryIndex.next();
339
- const userPatch = addNormalizedEntry(userIndex, userEntry);
340
- if (DEBUG_SNAPSHOT) {
341
- console.log(`[SessionManager:snapshot] sendMessage userPatch sessionId=${id} index=${userIndex} currentIndex=${msgStore.entryIndex.current()}`);
342
- }
343
- const userPatchSeq = msgStore.pushPatch(userPatch);
344
- // Emit directly to EventBus — the old pipeline was already destroyed so
345
- // MsgStore's patchListeners are empty at this point. Without this line
346
- // the user-message patch would never reach WebSocket subscribers.
347
- this.eventBus.emit('session:patch', { sessionId: id, patch: userPatch, seq: userPatchSeq });
553
+ const userEntryId = options.userEntryId ?? this.appendUserMessageEntry(session, message);
348
554
  const agentSessionId = session.externalSessionId
349
555
  ?? this.resolveAgentSessionId(id, session.logSnapshot);
350
556
  if (providerId && providerId !== session.providerId) {
351
557
  await this.runtimeCoordinator.disposeSession(id);
352
558
  }
353
- await this.startRuntimeTurn(session, message, agentSessionId, effectiveProviderId, resumeMode, userEntry.id);
354
- return session;
559
+ const processStart = this.reserveProcessStart(id);
560
+ try {
561
+ await this.startRuntimeTurn(session, message, agentSessionId, effectiveProviderId, resumeMode, userEntryId, false, expectedTeamRunInvocationId);
562
+ return session;
563
+ }
564
+ finally {
565
+ processStart.release();
566
+ }
355
567
  }
356
568
  finally {
569
+ releaseTeamRunAdmission?.();
357
570
  reservation.release();
358
571
  }
359
572
  }
@@ -361,6 +574,18 @@ export class SessionManager {
361
574
  const session = await prisma.session.findUnique({ where: { id } });
362
575
  if (!session)
363
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);
583
+ // Revoke TeamRun dispatch before waiting for runtime cleanup. A direct
584
+ // Session stop does not pass through TeamSchedulerService, so delaying this
585
+ // until cleanup succeeds would leave heartbeat/follow-up admission open.
586
+ if (!options.skipTeamRunReconcile && !this.isConversationSession(session)) {
587
+ await this.teamReconciler.handleSessionStopped(id);
588
+ }
364
589
  const terminalStatus = this.terminalSessions.get(id);
365
590
  const hasActiveTurn = this.runtimeCoordinator.hasActiveTurn(id);
366
591
  const persistedTerminal = [
@@ -375,10 +600,17 @@ export class SessionManager {
375
600
  // backing TeamRun invocation may still be waiting for a room reply, so
376
601
  // it must still pass through the cancellation reconciler.
377
602
  await this.runtimeCoordinator.disposeSession(id);
603
+ if (!await this.isRuntimeCleanupConfirmed(id)) {
604
+ this.terminalSessions.delete(id);
605
+ return session;
606
+ }
378
607
  this.maybeClearTerminalState(id);
379
608
  await pendingFinalization;
380
- if (!options.skipTeamRunReconcile && !this.isConversationSession(session)) {
381
- await this.teamReconciler.handleSessionStopped(id);
609
+ if (terminalStatus && !persistedTerminal) {
610
+ await prisma.session.updateMany({
611
+ where: { id, status: { notIn: [SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.CANCELLED] } },
612
+ data: { status: terminalStatus },
613
+ });
382
614
  }
383
615
  revokeAgentApiCredential(id);
384
616
  return session;
@@ -397,9 +629,14 @@ export class SessionManager {
397
629
  }
398
630
  // Explicit stop revokes the DriverSession-bound workspace-service credential.
399
631
  // A later follow-up reopens from the persisted external session id with a new credential.
400
- await this.runtimeCoordinator.disposeSession(id).catch((error) => {
401
- this.logSessionError('session.runtimeDispose', error, { sessionId: id });
402
- });
632
+ // Do not report a stopped TeamRun member until the owned process tree has
633
+ // confirmed cleanup. The scheduler will retain the invocation and block
634
+ // the next admission when this rejects, allowing a later retry/recovery.
635
+ await this.runtimeCoordinator.disposeSession(id);
636
+ if (!await this.isRuntimeCleanupConfirmed(id)) {
637
+ this.terminalSessions.delete(id);
638
+ return session;
639
+ }
403
640
  const msgStore = sessionMsgStoreManager.get(id);
404
641
  if (msgStore) {
405
642
  msgStore.pushFinished();
@@ -420,9 +657,6 @@ export class SessionManager {
420
657
  data: { status: SessionStatus.CANCELLED },
421
658
  });
422
659
  }
423
- if (!options.skipTeamRunReconcile && !this.isConversationSession(session)) {
424
- await this.teamReconciler.handleSessionStopped(id);
425
- }
426
660
  this.eventBus.emit('session:stopped', { sessionId: id });
427
661
  // Cancellation does not run normal terminal finalization, so release the
428
662
  // store after the CANCELLED snapshot has been persisted above.
@@ -467,7 +701,20 @@ export class SessionManager {
467
701
  }
468
702
  /** Close all runtime driver sessions during graceful server shutdown. */
469
703
  async destroyAll() {
704
+ this.stopConversationQueue();
470
705
  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
+ this.pendingRuntimeProcessEvents.clear();
715
+ for (const timer of this.pendingRuntimeEventExpiryTimers.values())
716
+ clearTimeout(timer);
717
+ this.pendingRuntimeEventExpiryTimers.clear();
471
718
  await Promise.allSettled(this.externalSessionPersistence.values());
472
719
  this.externalSessionPersistence.clear();
473
720
  this.terminalSessions.clear();
@@ -481,6 +728,127 @@ export class SessionManager {
481
728
  this.followUpReservations.clear();
482
729
  clearAgentApiCredentials();
483
730
  }
731
+ kickConversationQueue(sessionId) {
732
+ if (this.conversationQueueStopping || this.conversationQueueWorkers.has(sessionId)) {
733
+ return;
734
+ }
735
+ const worker = new Promise((resolve, reject) => {
736
+ setImmediate(() => {
737
+ this.processConversationQueue(sessionId).then(resolve, reject);
738
+ });
739
+ });
740
+ this.conversationQueueWorkers.set(sessionId, worker);
741
+ void worker
742
+ .catch((error) => {
743
+ this.logSessionError('conversation.queue', error, { sessionId });
744
+ })
745
+ .finally(() => {
746
+ if (this.conversationQueueWorkers.get(sessionId) === worker) {
747
+ this.conversationQueueWorkers.delete(sessionId);
748
+ }
749
+ // Cover an enqueue racing with the worker's final empty scan without
750
+ // creating a worker spin loop when the queue is actually empty.
751
+ if (!this.conversationQueueStopping) {
752
+ void prisma.conversationTurn.count({
753
+ where: { sessionId, status: CONVERSATION_TURN_QUEUED },
754
+ }).then((count) => {
755
+ if (count > 0)
756
+ this.kickConversationQueue(sessionId);
757
+ }).catch((error) => {
758
+ this.logSessionError('conversation.queueScan', error, { sessionId });
759
+ });
760
+ }
761
+ });
762
+ }
763
+ async processConversationQueue(sessionId) {
764
+ while (!this.conversationQueueStopping) {
765
+ const queued = await prisma.conversationTurn.findFirst({
766
+ where: { sessionId, status: CONVERSATION_TURN_QUEUED },
767
+ orderBy: [{ queuedAt: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
768
+ });
769
+ if (!queued)
770
+ return;
771
+ const claimed = await prisma.conversationTurn.updateMany({
772
+ where: { id: queued.id, status: CONVERSATION_TURN_QUEUED },
773
+ data: {
774
+ status: CONVERSATION_TURN_RUNNING,
775
+ attempts: { increment: 1 },
776
+ startedAt: new Date(),
777
+ lastError: null,
778
+ },
779
+ });
780
+ if (claimed.count !== 1)
781
+ continue;
782
+ try {
783
+ console.log(`[ConversationQueue] claimed session=${sessionId} turn=${queued.id} queueWaitMs=${Math.max(0, Date.now() - queued.queuedAt.getTime())} attempt=${queued.attempts + 1}`);
784
+ // A conversation accepts one ACP prompt at a time. Waiting here keeps
785
+ // queued follow-ups from cancelling the previous turn (and hitting
786
+ // RuntimeCoordinator's ten-second abandon timeout).
787
+ const previousTurnWaitStartedAt = Date.now();
788
+ await this.runtimeCoordinator.waitForTurnCompletion(sessionId);
789
+ console.log(`[ConversationQueue] priorTurnReady session=${sessionId} turn=${queued.id} waitMs=${Date.now() - previousTurnWaitStartedAt}`);
790
+ if (this.conversationQueueStopping) {
791
+ await this.requeueConversationTurn(queued.id);
792
+ return;
793
+ }
794
+ const session = await this.findSessionExecutionRecord(sessionId);
795
+ if (!session)
796
+ throw new NotFoundError('Session', sessionId);
797
+ this.ensureExecutionRecordIsLive(session);
798
+ const userEntryId = queued.userEntryId ?? `user:${queued.id}`;
799
+ this.appendUserMessageEntry(session, queued.message, userEntryId);
800
+ if (!queued.userEntryId) {
801
+ await prisma.conversationTurn.updateMany({
802
+ where: { id: queued.id, status: CONVERSATION_TURN_RUNNING },
803
+ data: { userEntryId },
804
+ });
805
+ }
806
+ const dispatchStartedAt = Date.now();
807
+ await this.sendMessage(sessionId, queued.message, queued.providerId ?? undefined, undefined, { userEntryId });
808
+ console.log(`[ConversationQueue] dispatched session=${sessionId} turn=${queued.id} dispatchMs=${Date.now() - dispatchStartedAt}`);
809
+ const turnWaitStartedAt = Date.now();
810
+ await this.runtimeCoordinator.waitForTurnCompletion(sessionId);
811
+ await prisma.conversationTurn.updateMany({
812
+ where: { id: queued.id, status: CONVERSATION_TURN_RUNNING },
813
+ data: {
814
+ status: CONVERSATION_TURN_COMPLETED,
815
+ completedAt: new Date(),
816
+ },
817
+ });
818
+ console.log(`[ConversationQueue] completed session=${sessionId} turn=${queued.id} runtimeWaitMs=${Date.now() - turnWaitStartedAt}`);
819
+ }
820
+ catch (error) {
821
+ if (this.conversationQueueStopping) {
822
+ await this.requeueConversationTurn(queued.id);
823
+ return;
824
+ }
825
+ const lastError = error instanceof Error ? error.message : String(error);
826
+ await prisma.conversationTurn.updateMany({
827
+ where: { id: queued.id, status: CONVERSATION_TURN_RUNNING },
828
+ data: {
829
+ status: CONVERSATION_TURN_FAILED,
830
+ lastError: lastError.slice(0, 2_000),
831
+ completedAt: new Date(),
832
+ },
833
+ });
834
+ const errorSummary = summarizeTextForLog(lastError);
835
+ console.log(`[ConversationQueue] failed session=${sessionId} turn=${queued.id} errorLength=${errorSummary.length} errorSha256=${errorSummary.sha256}`);
836
+ this.logSessionError('conversation.queueTurn', error, {
837
+ sessionId,
838
+ turnId: queued.id,
839
+ });
840
+ }
841
+ }
842
+ }
843
+ async requeueConversationTurn(turnId) {
844
+ await prisma.conversationTurn.updateMany({
845
+ where: { id: turnId, status: CONVERSATION_TURN_RUNNING },
846
+ data: {
847
+ status: CONVERSATION_TURN_QUEUED,
848
+ startedAt: null,
849
+ },
850
+ });
851
+ }
484
852
  resolveAgentSessionId(sessionId, logSnapshot) {
485
853
  const msgStore = sessionMsgStoreManager.get(sessionId);
486
854
  if (msgStore) {
@@ -500,7 +868,7 @@ export class SessionManager {
500
868
  }
501
869
  return null;
502
870
  }
503
- async startRuntimeTurn(session, prompt, resumeExternalSessionId, providerId = session.providerId, resumeMode = 'load', historyBoundaryEntryId) {
871
+ async startRuntimeTurn(session, prompt, resumeExternalSessionId, providerId = session.providerId, resumeMode = 'load', historyBoundaryEntryId, initialStart = false, expectedTeamRunInvocationId) {
504
872
  const workingDir = this.getExecutionWorkingDir(session);
505
873
  const env = ExecutionEnv.default(workingDir);
506
874
  if (providerId) {
@@ -523,14 +891,19 @@ export class SessionManager {
523
891
  this.logSessionError('session.snapshotRestore', error, { sessionId: session.id });
524
892
  }
525
893
  }
894
+ let launchClaimNumber = null;
526
895
  try {
527
- // Session status follows the logical Runtime turn, not the lifetime of
528
- // its backing OS process. ACP reuses one adapter process across turns,
529
- // so a follow-up must become RUNNING even when no process starts.
530
- await prisma.session.update({
531
- where: { id: session.id },
532
- data: { status: SessionStatus.RUNNING },
533
- });
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
+ }
534
907
  const runtimePrompt = buildWorkspaceRuntimePrompt(session, prompt);
535
908
  const handle = await this.runtimeCoordinator.startTurn({
536
909
  towerSessionId: session.id,
@@ -548,7 +921,9 @@ export class SessionManager {
548
921
  resumeExternalSessionId,
549
922
  resumeMode,
550
923
  historyBoundaryEntryId,
924
+ launchClaimNumber,
551
925
  });
926
+ await this.resolveReusedRuntimeLaunch(session.id, launchClaimNumber);
552
927
  // Terminal persistence is driven by Runtime turn events. Attach a catch
553
928
  // so the public start/message methods do not leave a rejected handle
554
929
  // unobserved after they have returned to the HTTP caller.
@@ -559,10 +934,21 @@ export class SessionManager {
559
934
  catch (error) {
560
935
  await this.runtimeCoordinator.disposeSession(session.id).catch(() => undefined);
561
936
  revokeAgentApiCredential(session.id);
562
- await prisma.session.update({
563
- where: { id: session.id },
564
- data: { status: SessionStatus.CANCELLED },
565
- }).catch(() => undefined);
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
+ const cleanupConfirmed = await this.isRuntimeCleanupConfirmed(session.id).catch(() => false);
946
+ if (cleanupConfirmed) {
947
+ await prisma.session.update({
948
+ where: { id: session.id },
949
+ data: { status: SessionStatus.CANCELLED },
950
+ }).catch(() => undefined);
951
+ }
566
952
  sessionMsgStoreManager.delete(session.id);
567
953
  this.releaseSnapshotPersistenceState(session.id);
568
954
  this.logSessionError('session.runtimeStart', error, {
@@ -575,6 +961,153 @@ export class SessionManager {
575
961
  throw normalizeExecutorStartError(error);
576
962
  }
577
963
  }
964
+ async claimRuntimeLaunch(sessionId, initialStart) {
965
+ return prisma.$transaction(async (tx) => {
966
+ const current = await tx.session.findUnique({
967
+ where: { id: sessionId },
968
+ select: { status: true, runtimeLaunchClaimCount: true, runtimeLaunchResolvedCount: true },
969
+ });
970
+ const admittedStatuses = initialStart
971
+ ? [SessionStatus.PENDING]
972
+ : [
973
+ SessionStatus.PENDING,
974
+ SessionStatus.RUNNING,
975
+ SessionStatus.COMPLETED,
976
+ SessionStatus.FAILED,
977
+ SessionStatus.CANCELLED,
978
+ ];
979
+ if (!current
980
+ || !admittedStatuses.includes(current.status)
981
+ || current.runtimeLaunchClaimCount !== current.runtimeLaunchResolvedCount) {
982
+ throw new ServiceError(initialStart
983
+ ? 'Session start was revoked before process ownership was acquired'
984
+ : 'Session follow-up was revoked before process ownership was acquired', 'SESSION_NOT_ADMITTED', 409);
985
+ }
986
+ const claimNumber = current.runtimeLaunchClaimCount + 1;
987
+ const admitted = await tx.session.updateMany({
988
+ where: {
989
+ id: sessionId,
990
+ status: current.status,
991
+ runtimeLaunchClaimCount: current.runtimeLaunchClaimCount,
992
+ runtimeLaunchResolvedCount: current.runtimeLaunchResolvedCount,
993
+ },
994
+ data: {
995
+ status: SessionStatus.RUNNING,
996
+ runtimeLaunchState: RUNTIME_LAUNCH_STATES.CLAIMED,
997
+ runtimeLaunchClaimCount: { increment: 1 },
998
+ runtimeLaunchDiagnostic: null,
999
+ runtimeLaunchNextDiagnosticAt: null,
1000
+ },
1001
+ });
1002
+ if (admitted.count !== 1) {
1003
+ throw new ServiceError('Session runtime launch admission changed concurrently', 'SESSION_NOT_ADMITTED', 409);
1004
+ }
1005
+ return claimNumber;
1006
+ });
1007
+ }
1008
+ async resolveReusedRuntimeLaunch(sessionId, claimNumber) {
1009
+ await prisma.session.updateMany({
1010
+ where: {
1011
+ id: sessionId,
1012
+ runtimeLaunchClaimCount: claimNumber,
1013
+ runtimeLaunchResolvedCount: claimNumber - 1,
1014
+ runtimeLaunchState: RUNTIME_LAUNCH_STATES.CLAIMED,
1015
+ },
1016
+ data: {
1017
+ runtimeLaunchState: RUNTIME_LAUNCH_STATES.REUSED,
1018
+ runtimeLaunchResolvedCount: { increment: 1 },
1019
+ runtimeLaunchDiagnostic: null,
1020
+ runtimeLaunchNextDiagnosticAt: null,
1021
+ },
1022
+ });
1023
+ }
1024
+ async resolvePreChildRuntimeLaunchFailure(sessionId, claimNumber, error) {
1025
+ await prisma.session.updateMany({
1026
+ where: {
1027
+ id: sessionId,
1028
+ runtimeLaunchClaimCount: claimNumber,
1029
+ runtimeLaunchResolvedCount: claimNumber - 1,
1030
+ runtimeLaunchState: RUNTIME_LAUNCH_STATES.CLAIMED,
1031
+ },
1032
+ data: {
1033
+ runtimeLaunchState: RUNTIME_LAUNCH_STATES.SAFE_PRE_CHILD_FAILURE,
1034
+ runtimeLaunchResolvedCount: { increment: 1 },
1035
+ runtimeLaunchDiagnostic: (error instanceof Error ? error.message : String(error)).slice(0, 2_000),
1036
+ runtimeLaunchNextDiagnosticAt: null,
1037
+ },
1038
+ });
1039
+ }
1040
+ async quarantineRuntimeLaunch(sessionId, claimNumber, error) {
1041
+ const diagnostic = `Runtime launch claim ${claimNumber} failed without proof that no child was created: ${error instanceof Error ? error.message : String(error)}`;
1042
+ await prisma.session.updateMany({
1043
+ where: {
1044
+ id: sessionId,
1045
+ runtimeLaunchClaimCount: claimNumber,
1046
+ runtimeLaunchResolvedCount: { lt: claimNumber },
1047
+ },
1048
+ data: {
1049
+ runtimeLaunchState: RUNTIME_LAUNCH_STATES.QUARANTINED,
1050
+ runtimeLaunchDiagnostic: diagnostic.slice(0, 2_000),
1051
+ runtimeLaunchDiagnosticCount: { increment: 1 },
1052
+ runtimeLaunchNextDiagnosticAt: new Date(Date.now() + 5 * 60_000),
1053
+ },
1054
+ });
1055
+ console.warn(`[SessionManager] Session ${sessionId} runtime launch quarantined: ${diagnostic}`);
1056
+ }
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
+ async assertTeamRunDispatchAdmitted(sessionId, expectedInvocationId) {
1088
+ const invocation = await prisma.agentInvocation.findFirst({
1089
+ where: {
1090
+ sessionId,
1091
+ ...(expectedInvocationId ? { id: expectedInvocationId } : {}),
1092
+ },
1093
+ select: { id: true, teamRunId: true, memberId: true, status: true, dispatchRevokedAt: true },
1094
+ });
1095
+ const member = invocation
1096
+ ? await prisma.teamMember.findUnique({
1097
+ where: { id: invocation.memberId },
1098
+ select: { teamRunId: true, membershipStatus: true },
1099
+ })
1100
+ : null;
1101
+ if (invocation?.dispatchRevokedAt
1102
+ || (expectedInvocationId && (!invocation
1103
+ || !['QUEUED', 'RUNNING', 'SESSION_ENDED', 'WAITING_ROOM_REPLY'].includes(invocation.status)))
1104
+ || (invocation && (!member
1105
+ || member.teamRunId !== invocation.teamRunId
1106
+ || member.membershipStatus !== 'ACTIVE'
1107
+ || !['QUEUED', 'RUNNING', 'SESSION_ENDED', 'WAITING_ROOM_REPLY'].includes(invocation.status)))) {
1108
+ throw new ServiceError('TeamRun dispatch was revoked before process ownership was acquired', 'SESSION_NOT_ADMITTED', 409);
1109
+ }
1110
+ }
578
1111
  handleRuntimeTurnEvent(envelope) {
579
1112
  const sessionId = envelope.towerSessionId;
580
1113
  const event = envelope.event;
@@ -673,30 +1206,245 @@ export class SessionManager {
673
1206
  });
674
1207
  }
675
1208
  async handleRuntimeProcessEvent(event) {
1209
+ this.prunePendingRuntimeProcessEvents();
1210
+ const launchClaimNumber = Number.isInteger(event.launchClaimNumber) && event.launchClaimNumber > 0
1211
+ ? event.launchClaimNumber
1212
+ : null;
676
1213
  if (event.type === 'started') {
677
- const processRecord = await prisma.$transaction(async (tx) => {
678
- const session = await tx.session.findUnique({
679
- where: { id: event.towerSessionId },
680
- include: { workspace: { include: { task: true } }, conversation: true },
1214
+ let processRecord;
1215
+ try {
1216
+ processRecord = await prisma.$transaction(async (tx) => {
1217
+ const session = await tx.session.findUnique({
1218
+ where: { id: event.towerSessionId },
1219
+ include: { workspace: { include: { task: true } }, conversation: true },
1220
+ });
1221
+ if (!session)
1222
+ throw new NotFoundError('Session', event.towerSessionId);
1223
+ this.ensureExecutionRecordIsLive(session);
1224
+ const launchClaimNumber = Number.isInteger(event.launchClaimNumber)
1225
+ && event.launchClaimNumber > 0
1226
+ ? event.launchClaimNumber
1227
+ : null;
1228
+ const runtimeInstanceId = typeof event.runtimeInstanceId === 'string'
1229
+ && event.runtimeInstanceId.length > 0
1230
+ ? event.runtimeInstanceId
1231
+ : null;
1232
+ const processGroupId = typeof event.processGroupId === 'string'
1233
+ && event.processGroupId.length > 0
1234
+ ? event.processGroupId
1235
+ : null;
1236
+ const birthMarker = typeof event.birthMarker === 'string'
1237
+ && event.birthMarker.length > 0
1238
+ ? event.birthMarker
1239
+ : null;
1240
+ const ownershipToken = typeof event.ownershipToken === 'string'
1241
+ && event.ownershipToken.length > 0
1242
+ ? event.ownershipToken
1243
+ : null;
1244
+ const identityComplete = runtimeInstanceId != null
1245
+ && Number.isInteger(event.pid)
1246
+ && event.pid > 0
1247
+ && processGroupId != null
1248
+ && birthMarker != null
1249
+ && ownershipToken != null;
1250
+ const existing = runtimeInstanceId
1251
+ ? await tx.executionProcess.findFirst({
1252
+ where: {
1253
+ sessionId: event.towerSessionId,
1254
+ runtimeInstanceId,
1255
+ launchClaimNumber,
1256
+ },
1257
+ select: { id: true, cleanupState: true },
1258
+ })
1259
+ : null;
1260
+ // started is a generation barrier and may be retried after a caller
1261
+ // timeout. Do not create a second row or advance the launch claim a
1262
+ // second time when the first transaction already committed.
1263
+ if (existing)
1264
+ return existing;
1265
+ const diagnostic = launchClaimNumber == null
1266
+ ? 'New runtime generation did not provide a valid launch claim number'
1267
+ : 'New runtime generation did not provide complete process ownership identity';
1268
+ const process = await tx.executionProcess.create({
1269
+ data: {
1270
+ sessionId: event.towerSessionId,
1271
+ launchClaimNumber,
1272
+ runtimeInstanceId,
1273
+ processGroupId,
1274
+ birthMarker,
1275
+ ownershipToken,
1276
+ cleanupState: identityComplete && launchClaimNumber != null ? 'ACTIVE' : 'QUARANTINED',
1277
+ cleanupError: identityComplete && launchClaimNumber != null ? null : diagnostic,
1278
+ cleanupAttemptCount: identityComplete && launchClaimNumber != null ? 0 : 1,
1279
+ nextCleanupRetryAt: identityComplete && launchClaimNumber != null
1280
+ ? null
1281
+ : new Date(Date.now() + 5 * 60_000),
1282
+ pid: Number.isInteger(event.pid) && event.pid > 0 ? event.pid : null,
1283
+ },
1284
+ select: { id: true, cleanupState: true },
1285
+ });
1286
+ if (launchClaimNumber == null) {
1287
+ await tx.session.update({
1288
+ where: { id: event.towerSessionId },
1289
+ data: {
1290
+ runtimeLaunchState: RUNTIME_LAUNCH_STATES.QUARANTINED,
1291
+ runtimeLaunchProcessCount: { increment: 1 },
1292
+ runtimeLaunchDiagnostic: diagnostic,
1293
+ runtimeLaunchDiagnosticCount: { increment: 1 },
1294
+ runtimeLaunchNextDiagnosticAt: new Date(Date.now() + 5 * 60_000),
1295
+ },
1296
+ });
1297
+ return process;
1298
+ }
1299
+ const resolved = await tx.session.updateMany({
1300
+ where: {
1301
+ id: event.towerSessionId,
1302
+ runtimeLaunchClaimCount: launchClaimNumber,
1303
+ runtimeLaunchResolvedCount: launchClaimNumber - 1,
1304
+ },
1305
+ data: {
1306
+ runtimeLaunchState: identityComplete
1307
+ ? RUNTIME_LAUNCH_STATES.PROCESS_RECORDED
1308
+ : RUNTIME_LAUNCH_STATES.QUARANTINED,
1309
+ runtimeLaunchResolvedCount: { increment: 1 },
1310
+ runtimeLaunchProcessCount: { increment: 1 },
1311
+ runtimeLaunchDiagnostic: identityComplete ? null : diagnostic,
1312
+ runtimeLaunchDiagnosticCount: identityComplete ? undefined : { increment: 1 },
1313
+ runtimeLaunchNextDiagnosticAt: identityComplete ? null : new Date(Date.now() + 5 * 60_000),
1314
+ },
1315
+ });
1316
+ if (resolved.count !== 1) {
1317
+ throw new ServiceError(`Runtime process started outside launch claim ${launchClaimNumber}`, 'RUNTIME_LAUNCH_CLAIM_MISMATCH', 409);
1318
+ }
1319
+ return process;
681
1320
  });
682
- if (!session)
683
- throw new NotFoundError('Session', event.towerSessionId);
684
- this.ensureExecutionRecordIsLive(session);
685
- return tx.executionProcess.create({
686
- data: { sessionId: event.towerSessionId, pid: event.pid },
687
- select: { id: true },
1321
+ }
1322
+ catch (error) {
1323
+ if (launchClaimNumber != null) {
1324
+ this.clearPendingRuntimeEventExpiryTimer(eventKey(event.towerSessionId, event.runtimeInstanceId, launchClaimNumber));
1325
+ this.pendingRuntimeProcessEvents.delete(eventKey(event.towerSessionId, event.runtimeInstanceId, launchClaimNumber));
1326
+ }
1327
+ await this.quarantineRuntimeProcessEvent(event, `Runtime started persistence failed permanently: ${error instanceof Error ? error.message : String(error)}`);
1328
+ throw error;
1329
+ }
1330
+ if (typeof event.runtimeInstanceId === 'string'
1331
+ && event.runtimeInstanceId.length > 0
1332
+ && !['CONFIRMED', 'QUARANTINED'].includes(processRecord.cleanupState)) {
1333
+ this.runtimeProcessIds.set(eventKey(event.towerSessionId, event.runtimeInstanceId, launchClaimNumber ?? 0), processRecord.id);
1334
+ }
1335
+ if (launchClaimNumber == null)
1336
+ return;
1337
+ const key = eventKey(event.towerSessionId, event.runtimeInstanceId, launchClaimNumber);
1338
+ const buffered = this.pendingRuntimeProcessEvents.get(key);
1339
+ if (buffered) {
1340
+ this.pendingRuntimeProcessEvents.delete(key);
1341
+ this.clearPendingRuntimeEventExpiryTimer(key);
1342
+ for (const bufferedEvent of buffered) {
1343
+ await this.handleRuntimeProcessEvent(bufferedEvent.event);
1344
+ }
1345
+ }
1346
+ return;
1347
+ }
1348
+ const processKey = launchClaimNumber != null
1349
+ ? eventKey(event.towerSessionId, event.runtimeInstanceId, launchClaimNumber)
1350
+ : undefined;
1351
+ const processId = processKey ? this.runtimeProcessIds.get(processKey) : undefined;
1352
+ const processRecord = launchClaimNumber != null
1353
+ ? await prisma.executionProcess.findFirst({
1354
+ where: {
1355
+ ...(processId ? { id: processId } : {}),
1356
+ sessionId: event.towerSessionId,
1357
+ runtimeInstanceId: event.runtimeInstanceId,
1358
+ launchClaimNumber,
1359
+ },
1360
+ select: {
1361
+ id: true,
1362
+ launchClaimNumber: true,
1363
+ processGroupId: true,
1364
+ birthMarker: true,
1365
+ ownershipToken: true,
1366
+ cleanupState: true,
1367
+ },
1368
+ })
1369
+ : null;
1370
+ if (!processRecord) {
1371
+ if (launchClaimNumber == null) {
1372
+ await this.quarantineRuntimeProcessEvent(event, 'Runtime process event arrived before started persistence without a valid launch claim');
1373
+ return;
1374
+ }
1375
+ const key = eventKey(event.towerSessionId, event.runtimeInstanceId, launchClaimNumber);
1376
+ const pending = this.pendingRuntimeProcessEvents.get(key) ?? [];
1377
+ if (pending.length >= PENDING_RUNTIME_EVENTS_PER_KEY
1378
+ || this.pendingRuntimeEventCount() >= PENDING_RUNTIME_EVENTS_GLOBAL) {
1379
+ this.pendingRuntimeProcessEvents.delete(key);
1380
+ this.clearPendingRuntimeEventExpiryTimer(key);
1381
+ await this.quarantineRuntimeProcessEvent(event, 'Runtime process event buffer overflow; event generation quarantined');
1382
+ return;
1383
+ }
1384
+ pending.push({ event, expiresAt: Date.now() + PENDING_RUNTIME_EVENT_TTL_MS });
1385
+ this.pendingRuntimeProcessEvents.set(key, pending);
1386
+ if (!this.pendingRuntimeEventExpiryTimers.has(key)) {
1387
+ const timer = setTimeout(() => {
1388
+ this.pendingRuntimeProcessEvents.delete(key);
1389
+ this.pendingRuntimeEventExpiryTimers.delete(key);
1390
+ }, PENDING_RUNTIME_EVENT_TTL_MS);
1391
+ timer.unref?.();
1392
+ this.pendingRuntimeEventExpiryTimers.set(key, timer);
1393
+ }
1394
+ // A process event without its started row is durable evidence that the
1395
+ // launch handoff was interrupted. Keep the session blocked/quarantined
1396
+ // while retaining the in-memory event for a later started retry; never
1397
+ // treat an empty process set as a safe pre-child failure.
1398
+ await prisma.session.updateMany({
1399
+ where: {
1400
+ id: event.towerSessionId,
1401
+ // A concurrent started transaction has already established durable
1402
+ // ownership; it must win over this stale no-row observation.
1403
+ runtimeLaunchState: {
1404
+ notIn: [RUNTIME_LAUNCH_STATES.PROCESS_RECORDED, RUNTIME_LAUNCH_STATES.REUSED],
1405
+ },
1406
+ },
1407
+ data: {
1408
+ runtimeLaunchState: RUNTIME_LAUNCH_STATES.QUARANTINED,
1409
+ runtimeLaunchDiagnostic: 'Runtime process event arrived before started persistence',
1410
+ runtimeLaunchDiagnosticCount: { increment: 1 },
1411
+ runtimeLaunchNextDiagnosticAt: new Date(Date.now() + 5 * 60_000),
1412
+ },
1413
+ }).catch((error) => {
1414
+ this.logSessionError('session.processEventBeforeStarted', error, {
1415
+ sessionId: event.towerSessionId,
1416
+ runtimeInstanceId: event.runtimeInstanceId,
1417
+ eventType: event.type,
688
1418
  });
689
1419
  });
690
- this.runtimeProcessIds.set(event.runtimeInstanceId, processRecord.id);
691
1420
  return;
692
1421
  }
693
- const processId = this.runtimeProcessIds.get(event.runtimeInstanceId);
694
- if (!processId)
1422
+ if (launchClaimNumber == null || processRecord.launchClaimNumber !== launchClaimNumber) {
1423
+ await this.quarantineRuntimeProcessEvent(event, 'Runtime process event launch claim did not match its persisted generation');
1424
+ return;
1425
+ }
1426
+ if (!processId && !['CONFIRMED', 'QUARANTINED'].includes(processRecord.cleanupState)) {
1427
+ this.runtimeProcessIds.set(processKey, processRecord.id);
1428
+ }
1429
+ if (event.type === 'tree_cleanup_completed') {
1430
+ await this.markRuntimeProcessCleanupState(event.towerSessionId, event.runtimeInstanceId, 'CONFIRMED', undefined, launchClaimNumber);
1431
+ if (processKey)
1432
+ this.runtimeProcessIds.delete(processKey);
695
1433
  return;
696
- this.runtimeProcessIds.delete(event.runtimeInstanceId);
697
- await prisma.executionProcess.update({
698
- where: { id: processId },
699
- data: { exitCode: event.exitCode },
1434
+ }
1435
+ const rootExitConfirmsCleanup = processRecord != null
1436
+ && processRecord.processGroupId == null
1437
+ && processRecord.birthMarker == null
1438
+ && processRecord.ownershipToken == null;
1439
+ await prisma.executionProcess.updateMany({
1440
+ where: {
1441
+ sessionId: event.towerSessionId,
1442
+ runtimeInstanceId: event.runtimeInstanceId,
1443
+ ...(launchClaimNumber != null ? { launchClaimNumber } : {}),
1444
+ },
1445
+ data: {
1446
+ exitCode: event.exitCode,
1447
+ },
700
1448
  }).catch((error) => {
701
1449
  this.logSessionError('session.processExit', error, {
702
1450
  sessionId: event.towerSessionId,
@@ -704,6 +1452,134 @@ export class SessionManager {
704
1452
  exitCode: event.exitCode,
705
1453
  });
706
1454
  });
1455
+ if (rootExitConfirmsCleanup) {
1456
+ await this.markRuntimeProcessCleanupState(event.towerSessionId, event.runtimeInstanceId, 'CONFIRMED', undefined, launchClaimNumber);
1457
+ }
1458
+ else if (processRecord) {
1459
+ await this.markRuntimeProcessCleanupPending(event.towerSessionId, event.runtimeInstanceId, launchClaimNumber);
1460
+ }
1461
+ if (rootExitConfirmsCleanup) {
1462
+ if (processKey)
1463
+ this.runtimeProcessIds.delete(processKey);
1464
+ }
1465
+ }
1466
+ pendingRuntimeEventCount() {
1467
+ let count = 0;
1468
+ for (const events of this.pendingRuntimeProcessEvents.values())
1469
+ count += events.length;
1470
+ return count;
1471
+ }
1472
+ clearPendingRuntimeProcessEvents(sessionId) {
1473
+ for (const key of this.pendingRuntimeProcessEvents.keys()) {
1474
+ if (key.startsWith(`${sessionId}:`)) {
1475
+ this.pendingRuntimeProcessEvents.delete(key);
1476
+ this.clearPendingRuntimeEventExpiryTimer(key);
1477
+ }
1478
+ }
1479
+ }
1480
+ clearPendingRuntimeEventExpiryTimer(key) {
1481
+ const timer = this.pendingRuntimeEventExpiryTimers.get(key);
1482
+ if (timer)
1483
+ clearTimeout(timer);
1484
+ this.pendingRuntimeEventExpiryTimers.delete(key);
1485
+ }
1486
+ prunePendingRuntimeProcessEvents() {
1487
+ const now = Date.now();
1488
+ for (const [key, events] of this.pendingRuntimeProcessEvents) {
1489
+ const live = events.filter((entry) => entry.expiresAt > now);
1490
+ if (live.length === 0) {
1491
+ this.pendingRuntimeProcessEvents.delete(key);
1492
+ this.clearPendingRuntimeEventExpiryTimer(key);
1493
+ }
1494
+ else if (live.length !== events.length)
1495
+ this.pendingRuntimeProcessEvents.set(key, live);
1496
+ }
1497
+ }
1498
+ async quarantineRuntimeProcessEvent(event, diagnostic) {
1499
+ await prisma.session.updateMany({
1500
+ where: { id: event.towerSessionId },
1501
+ data: {
1502
+ runtimeLaunchState: RUNTIME_LAUNCH_STATES.QUARANTINED,
1503
+ runtimeLaunchDiagnostic: diagnostic,
1504
+ runtimeLaunchDiagnosticCount: { increment: 1 },
1505
+ runtimeLaunchNextDiagnosticAt: new Date(Date.now() + 5 * 60_000),
1506
+ },
1507
+ }).catch((error) => {
1508
+ this.logSessionError('session.processEventQuarantine', error, {
1509
+ sessionId: event.towerSessionId,
1510
+ runtimeInstanceId: event.runtimeInstanceId,
1511
+ });
1512
+ });
1513
+ }
1514
+ async markRuntimeProcessCleanupPending(sessionId, runtimeInstanceId, launchClaimNumber) {
1515
+ if (!Number.isInteger(launchClaimNumber) || launchClaimNumber <= 0) {
1516
+ await this.quarantinePersistedRuntimeProcess(sessionId, runtimeInstanceId, 'Runtime cleanup transition is missing launchClaimNumber; owner remains quarantined', launchClaimNumber);
1517
+ return;
1518
+ }
1519
+ const resolvedClaim = launchClaimNumber;
1520
+ await prisma.executionProcess.updateMany({
1521
+ where: {
1522
+ sessionId,
1523
+ runtimeInstanceId,
1524
+ launchClaimNumber: resolvedClaim,
1525
+ cleanupState: { notIn: ['CONFIRMED', 'QUARANTINED'] },
1526
+ },
1527
+ data: {
1528
+ cleanupState: 'PENDING',
1529
+ cleanupError: null,
1530
+ nextCleanupRetryAt: null,
1531
+ },
1532
+ }).catch((error) => {
1533
+ this.logSessionError('session.processCleanupPending', error, { sessionId, runtimeInstanceId });
1534
+ });
1535
+ }
1536
+ async markRuntimeProcessCleanupState(sessionId, runtimeInstanceId, cleanupState, cleanupError, launchClaimNumber) {
1537
+ if (!Number.isInteger(launchClaimNumber) || launchClaimNumber <= 0) {
1538
+ await this.quarantinePersistedRuntimeProcess(sessionId, runtimeInstanceId, 'Runtime cleanup transition is missing launchClaimNumber; owner remains quarantined', launchClaimNumber);
1539
+ return;
1540
+ }
1541
+ const resolvedClaim = launchClaimNumber;
1542
+ await prisma.$transaction(async (tx) => {
1543
+ const record = await tx.executionProcess.findFirst({
1544
+ where: { sessionId, runtimeInstanceId, launchClaimNumber: resolvedClaim },
1545
+ select: { id: true, cleanupAttemptCount: true, cleanupState: true },
1546
+ });
1547
+ if (!record)
1548
+ return;
1549
+ if (cleanupState === 'CONFIRMED') {
1550
+ if (record.cleanupState === 'QUARANTINED')
1551
+ return;
1552
+ await tx.executionProcess.updateMany({
1553
+ where: { id: record.id, sessionId, runtimeInstanceId, launchClaimNumber: resolvedClaim },
1554
+ data: {
1555
+ cleanupState,
1556
+ cleanupError: null,
1557
+ nextCleanupRetryAt: null,
1558
+ },
1559
+ });
1560
+ this.runtimeProcessIds.delete(eventKey(sessionId, runtimeInstanceId, resolvedClaim));
1561
+ return;
1562
+ }
1563
+ const attempt = record.cleanupAttemptCount + 1;
1564
+ const delay = PROCESS_CLEANUP_RETRY_DELAYS_MS[Math.min(attempt - 1, PROCESS_CLEANUP_RETRY_DELAYS_MS.length - 1)];
1565
+ await tx.executionProcess.updateMany({
1566
+ where: {
1567
+ id: record.id,
1568
+ sessionId,
1569
+ runtimeInstanceId,
1570
+ launchClaimNumber: resolvedClaim,
1571
+ cleanupState: { notIn: ['CONFIRMED', 'QUARANTINED'] },
1572
+ },
1573
+ data: {
1574
+ cleanupState,
1575
+ cleanupError: cleanupError?.slice(0, 2_000) ?? 'Process cleanup failed',
1576
+ cleanupAttemptCount: attempt,
1577
+ nextCleanupRetryAt: new Date(Date.now() + delay),
1578
+ },
1579
+ });
1580
+ }).catch((error) => {
1581
+ this.logSessionError('session.processCleanupState', error, { sessionId, runtimeInstanceId, cleanupState });
1582
+ });
707
1583
  }
708
1584
  normalizeRuntimeType(value) {
709
1585
  return value === RuntimeType.ACP ? RuntimeType.ACP : RuntimeType.CLI;
@@ -955,6 +1831,48 @@ export class SessionManager {
955
1831
  },
956
1832
  });
957
1833
  }
1834
+ appendUserMessageEntry(session, message, entryId) {
1835
+ const sessionId = session.id;
1836
+ const isNewStore = !sessionMsgStoreManager.has(sessionId);
1837
+ const msgStore = sessionMsgStoreManager.getOrCreate(sessionId);
1838
+ if (isNewStore && session.logSnapshot) {
1839
+ try {
1840
+ msgStore.restoreFromSnapshot(JSON.parse(session.logSnapshot));
1841
+ }
1842
+ catch (error) {
1843
+ console.error(`[SessionManager] Failed to restore snapshot for session ${sessionId}:`, error);
1844
+ }
1845
+ }
1846
+ // Heal index drift caused by previously failed patches (e.g. invalid value).
1847
+ const preflightSnapshot = msgStore.getSnapshot();
1848
+ const expectedIndex = preflightSnapshot.entries.length;
1849
+ const currentIndex = msgStore.entryIndex.current();
1850
+ if (currentIndex !== expectedIndex) {
1851
+ if (DEBUG_SNAPSHOT) {
1852
+ console.warn(`[SessionManager:snapshot] rebase entryIndex sessionId=${sessionId} currentIndex=${currentIndex} expectedIndex=${expectedIndex}`);
1853
+ }
1854
+ msgStore.entryIndex.startFrom(expectedIndex);
1855
+ }
1856
+ // Queue retries may reach this point after the patch was already applied
1857
+ // but before its durable userEntryId update. Reusing the stable id makes
1858
+ // the replay a no-op instead of appending the same message twice.
1859
+ if (entryId) {
1860
+ const existing = preflightSnapshot.entries.find((entry) => entry.id === entryId);
1861
+ if (existing)
1862
+ return entryId;
1863
+ }
1864
+ const userEntry = createUserMessage(message, entryId);
1865
+ const userIndex = msgStore.entryIndex.next();
1866
+ const userPatch = addNormalizedEntry(userIndex, userEntry);
1867
+ if (DEBUG_SNAPSHOT) {
1868
+ console.log(`[SessionManager:snapshot] userPatch sessionId=${sessionId} index=${userIndex} currentIndex=${msgStore.entryIndex.current()}`);
1869
+ }
1870
+ const userPatchSeq = msgStore.pushPatch(userPatch);
1871
+ // Emit directly to EventBus — a previous pipeline may have been destroyed
1872
+ // before the queued turn starts, leaving no MsgStore patch listener.
1873
+ this.eventBus.emit('session:patch', { sessionId, patch: userPatch, seq: userPatchSeq });
1874
+ return userEntry.id;
1875
+ }
958
1876
  isConversationSession(session) {
959
1877
  return session.context === SessionContext.CONVERSATION || Boolean(session.conversationId);
960
1878
  }
@@ -1198,7 +2116,7 @@ export class SessionManager {
1198
2116
  }
1199
2117
  if (!this.isCurrentGeneration(sessionId, generation))
1200
2118
  return;
1201
- const handledByTeamRun = await this.teamReconciler.handleSessionExit(sessionId);
2119
+ const handledByTeamRun = await this.teamReconciler.handleSessionExit(sessionId, options.runtimeInstanceId);
1202
2120
  if (!this.isCurrentGeneration(sessionId, generation))
1203
2121
  return;
1204
2122
  if (!isFailed) {
@@ -1224,6 +2142,7 @@ export class SessionManager {
1224
2142
  if (this.isCurrentGeneration(sessionId, generation)) {
1225
2143
  sessionMsgStoreManager.delete(sessionId);
1226
2144
  this.releaseSnapshotPersistenceState(sessionId);
2145
+ this.clearPendingRuntimeProcessEvents(sessionId);
1227
2146
  }
1228
2147
  }
1229
2148
  beginSessionExecution(sessionId) {