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
@@ -2,6 +2,8 @@ import { TaskStatus } from '../types/index.js';
2
2
  import { prisma } from '../utils/index.js';
3
3
  import { emitTeamRunInvalidated } from './team-run-events.js';
4
4
  import { isTaskDeleted } from './deleted-task-guard.js';
5
+ import { acquireTeamMemberAdmission } from './team-member-admission-barrier.js';
6
+ import { evaluateSessionRuntimeCleanup, reportDueRuntimeCleanupQuarantines, } from './session-runtime-cleanup-gate.js';
5
7
  // 统一补催/唤醒退避:指数增长(×2)封顶 5min,共 10 档(约 42min 触达上限)。
6
8
  const DEFAULT_REMINDER_DELAYS_MS = [
7
9
  60_000, 120_000, 240_000, 300_000, 300_000, 300_000, 300_000, 300_000, 300_000, 300_000,
@@ -52,7 +54,7 @@ export class TeamReconcilerService {
52
54
  this.heartbeatIdleThresholdMs = dependencies.heartbeatIdleThresholdMs;
53
55
  this.absoluteNudgeBudgetMs = dependencies.absoluteNudgeBudgetMs ?? DEFAULT_ABSOLUTE_NUDGE_BUDGET_MS;
54
56
  }
55
- async handleSessionExit(sessionId) {
57
+ async handleSessionExit(sessionId, expectedRuntimeInstanceId) {
56
58
  const invocation = await prisma.agentInvocation.findFirst({
57
59
  where: { sessionId },
58
60
  select: { id: true },
@@ -60,7 +62,7 @@ export class TeamReconcilerService {
60
62
  if (!invocation) {
61
63
  return false;
62
64
  }
63
- await this.reconcileInvocation(invocation.id);
65
+ await this.reconcileInvocation(invocation.id, expectedRuntimeInstanceId);
64
66
  return true;
65
67
  }
66
68
  async handleSessionStopped(sessionId) {
@@ -69,49 +71,76 @@ export class TeamReconcilerService {
69
71
  select: {
70
72
  id: true,
71
73
  teamRunId: true,
74
+ memberId: true,
75
+ status: true,
72
76
  teamRun: { select: { task: { select: { deletedAt: true } } } },
73
77
  },
74
78
  });
75
79
  if (!invocation) {
76
- return false;
80
+ return [];
77
81
  }
78
- const cancelled = await prisma.agentInvocation.updateMany({
79
- where: {
80
- id: invocation.id,
81
- status: { in: ACTIVE_INVOCATION_STATUSES },
82
- },
83
- data: {
84
- status: 'CANCELLED',
85
- nextRoomReplyReminderAt: null,
86
- },
82
+ if (TERMINAL_INVOCATION_STATUSES.includes(invocation.status)) {
83
+ return this.afterInvocationTerminal(invocation.teamRunId, invocation.id);
84
+ }
85
+ await prisma.agentInvocation.updateMany({
86
+ where: { id: invocation.id, status: { in: ACTIVE_INVOCATION_STATUSES } },
87
+ data: { dispatchRevokedAt: this.now(), nextRoomReplyReminderAt: null },
87
88
  });
88
- this.clearReminderTimer(invocation.id);
89
- if (cancelled.count !== 1) {
90
- return true;
89
+ return this.terminalizeRevokedInvocation(invocation.id);
90
+ }
91
+ async isSessionRuntimeCleanupConfirmed(sessionId) {
92
+ return (await evaluateSessionRuntimeCleanup(sessionId, {
93
+ hasActiveTurn: (candidateId) => this.hasActiveTurn(candidateId),
94
+ hasRuntimeProcessOwner: this.sessionMessenger?.hasRuntimeProcessOwner
95
+ ? (runtimeInstanceId, ownerSessionId, launchClaimNumber) => this.sessionMessenger.hasRuntimeProcessOwner(runtimeInstanceId, ownerSessionId ?? sessionId, launchClaimNumber)
96
+ : undefined,
97
+ }, this.now())).confirmed;
98
+ }
99
+ async reconcileInvocation(invocationId, expectedRuntimeInstanceId) {
100
+ const candidate = await prisma.agentInvocation.findUnique({
101
+ where: { id: invocationId },
102
+ select: { teamRunId: true, memberId: true },
103
+ });
104
+ if (!candidate) {
105
+ return;
91
106
  }
92
- await this.emitTeamRunInvalidated(invocation.teamRunId, ['agent-invocations', 'team-run'], 'agent-invocation-updated');
93
- if (isTaskDeleted(invocation.teamRun.task)) {
94
- await this.syncTerminalWorkRequest(invocation.id);
107
+ let result;
108
+ const releaseAdmission = await acquireTeamMemberAdmission(candidate.teamRunId, candidate.memberId);
109
+ try {
110
+ result = await this.reconcileInvocationUnderAdmission(invocationId);
111
+ }
112
+ finally {
113
+ releaseAdmission();
114
+ }
115
+ if (result.kind === 'task_deleted') {
95
116
  const scheduler = await this.getScheduler();
96
- scheduler.releaseInvocationLocks(invocation.id);
97
- return true;
117
+ scheduler.releaseInvocationLocks(invocationId);
118
+ return;
119
+ }
120
+ if (result.kind === 'terminal') {
121
+ await this.emitTeamRunInvalidated(result.teamRunId, ['agent-invocations', 'team-run'], 'agent-invocation-updated');
122
+ this.clearReminderTimer(result.invocationId);
123
+ await this.afterInvocationTerminal(result.teamRunId, result.invocationId, expectedRuntimeInstanceId);
98
124
  }
99
- await this.afterInvocationTerminal(invocation.teamRunId, invocation.id);
100
- return true;
101
125
  }
102
- async reconcileInvocation(invocationId) {
126
+ async reconcileInvocationUnderAdmission(invocationId) {
103
127
  const invocation = await prisma.agentInvocation.findUnique({
104
128
  where: { id: invocationId },
105
129
  include: { teamRun: { select: { heartbeatTimeoutMinutes: true, task: { select: { deletedAt: true } } } } },
106
130
  });
107
- if (!invocation) {
108
- return;
109
- }
131
+ if (!invocation)
132
+ return { kind: 'none' };
110
133
  if (isTaskDeleted(invocation.teamRun.task)) {
111
- const scheduler = await this.getScheduler();
112
- scheduler.releaseInvocationLocks(invocation.id);
113
134
  this.clearReminderTimer(invocation.id);
114
- return;
135
+ return { kind: 'task_deleted' };
136
+ }
137
+ // Stop admission is authoritative for all reminder/follow-up paths. A
138
+ // revoked invocation is completed by the cleanup recovery boundary once
139
+ // its owned tree is confirmed; it must never be nudged back into a turn.
140
+ if (invocation.dispatchRevokedAt
141
+ && ACTIVE_INVOCATION_STATUSES.includes(invocation.status)) {
142
+ this.clearReminderTimer(invocation.id);
143
+ return { kind: 'none' };
115
144
  }
116
145
  const hasRoomReply = await prisma.roomMessage.count({
117
146
  where: {
@@ -122,8 +151,12 @@ export class TeamReconcilerService {
122
151
  },
123
152
  }) > 0;
124
153
  if (hasRoomReply) {
125
- await prisma.agentInvocation.update({
126
- where: { id: invocation.id },
154
+ const completed = await prisma.agentInvocation.updateMany({
155
+ where: {
156
+ id: invocation.id,
157
+ status: { in: ACTIVE_INVOCATION_STATUSES },
158
+ dispatchRevokedAt: null,
159
+ },
127
160
  data: {
128
161
  status: 'COMPLETED',
129
162
  roomReplyReminderCount: 0,
@@ -131,20 +164,23 @@ export class TeamReconcilerService {
131
164
  firstNudgeAt: null,
132
165
  },
133
166
  });
134
- await this.emitTeamRunInvalidated(invocation.teamRunId, ['agent-invocations', 'team-run'], 'agent-invocation-updated');
135
- this.clearReminderTimer(invocation.id);
136
- await this.afterInvocationTerminal(invocation.teamRunId, invocation.id);
137
- return;
167
+ return completed.count === 1
168
+ ? { kind: 'terminal', teamRunId: invocation.teamRunId, invocationId: invocation.id }
169
+ : { kind: 'none' };
138
170
  }
139
171
  if (invocation.status === 'WAITING_ROOM_REPLY'
140
172
  && invocation.nextRoomReplyReminderAt
141
173
  && invocation.nextRoomReplyReminderAt.getTime() > this.now().getTime()) {
142
174
  this.scheduleReminderTimer(invocation.id, invocation.nextRoomReplyReminderAt);
143
- return;
175
+ return { kind: 'none' };
144
176
  }
145
177
  if (invocation.roomReplyReminderCount >= this.maxRoomReplyReminders) {
146
- await prisma.agentInvocation.update({
147
- where: { id: invocation.id },
178
+ const failed = await prisma.agentInvocation.updateMany({
179
+ where: {
180
+ id: invocation.id,
181
+ status: { in: ACTIVE_INVOCATION_STATUSES },
182
+ dispatchRevokedAt: null,
183
+ },
148
184
  data: {
149
185
  status: 'FAILED',
150
186
  roomReplyReminderCount: 0,
@@ -152,31 +188,38 @@ export class TeamReconcilerService {
152
188
  firstNudgeAt: null,
153
189
  },
154
190
  });
155
- await this.emitTeamRunInvalidated(invocation.teamRunId, ['agent-invocations', 'team-run'], 'agent-invocation-updated');
156
- this.clearReminderTimer(invocation.id);
157
- await this.afterInvocationTerminal(invocation.teamRunId, invocation.id);
158
- return;
191
+ return failed.count === 1
192
+ ? { kind: 'terminal', teamRunId: invocation.teamRunId, invocationId: invocation.id }
193
+ : { kind: 'none' };
159
194
  }
160
195
  const nextReminderCount = invocation.roomReplyReminderCount + 1;
161
196
  const nextReminderAt = this.addDelay(this.now(), this.getReminderDelayMs(nextReminderCount));
162
- await prisma.agentInvocation.update({
163
- where: { id: invocation.id },
197
+ const claimed = await prisma.agentInvocation.updateMany({
198
+ where: {
199
+ id: invocation.id,
200
+ status: { in: ACTIVE_INVOCATION_STATUSES },
201
+ dispatchRevokedAt: null,
202
+ },
164
203
  data: {
165
204
  status: 'WAITING_ROOM_REPLY',
166
205
  roomReplyReminderCount: nextReminderCount,
167
206
  nextRoomReplyReminderAt: nextReminderAt,
168
207
  },
169
208
  });
209
+ if (claimed.count !== 1)
210
+ return { kind: 'none' };
170
211
  await this.emitTeamRunInvalidated(invocation.teamRunId, ['agent-invocations', 'team-run'], 'agent-invocation-updated');
171
212
  this.scheduleReminderTimer(invocation.id, nextReminderAt);
172
213
  if (invocation.sessionId) {
173
- await this.sendRoomReplyReminder(invocation.sessionId);
214
+ await this.sendRoomReplyReminder(invocation.sessionId, invocation.id);
174
215
  }
216
+ return { kind: 'none' };
175
217
  }
176
218
  async reconcileDueRoomReplyReminders(limit = 50) {
177
219
  const dueInvocations = await prisma.agentInvocation.findMany({
178
220
  where: {
179
221
  status: 'WAITING_ROOM_REPLY',
222
+ dispatchRevokedAt: null,
180
223
  nextRoomReplyReminderAt: { lte: this.now() },
181
224
  },
182
225
  select: { id: true },
@@ -242,13 +285,35 @@ export class TeamReconcilerService {
242
285
  */
243
286
  async reconcileStalledInvocations() {
244
287
  const candidates = await prisma.agentInvocation.findMany({
245
- where: { status: 'RUNNING', sessionId: { not: null } },
288
+ where: { status: 'RUNNING', dispatchRevokedAt: null, sessionId: { not: null } },
246
289
  include: { teamRun: { select: { heartbeatTimeoutMinutes: true, task: { select: { deletedAt: true } } } } },
247
290
  });
248
291
  const now = this.now();
249
292
  for (const invocation of candidates) {
250
293
  try {
251
- await this.reconcileStalledCandidate(invocation, now);
294
+ let action = { kind: 'none' };
295
+ const releaseAdmission = await acquireTeamMemberAdmission(invocation.teamRunId, invocation.memberId);
296
+ try {
297
+ const current = await prisma.agentInvocation.findUnique({
298
+ where: { id: invocation.id },
299
+ include: { teamRun: { select: { heartbeatTimeoutMinutes: true, task: { select: { deletedAt: true } } } } },
300
+ });
301
+ if (current) {
302
+ action = await this.reconcileStalledCandidate(current, now);
303
+ }
304
+ }
305
+ finally {
306
+ releaseAdmission();
307
+ }
308
+ if (action.kind === 'nudge') {
309
+ const nudgeSent = await this.sendHeartbeatNudge(action.sessionId, action.invocationId);
310
+ if (!nudgeSent && this.isSessionPipelineMissing(action.sessionId)) {
311
+ action = await this.claimStalledTerminal(action.invocationId, action.sessionId, now);
312
+ }
313
+ }
314
+ if (action.kind === 'terminal') {
315
+ await this.finishStalledTerminal(action);
316
+ }
252
317
  }
253
318
  catch (error) {
254
319
  this.logCandidateFailure('stalled invocation', invocation.id, error);
@@ -261,13 +326,15 @@ export class TeamReconcilerService {
261
326
  */
262
327
  async reconcileOrphanInvocations() {
263
328
  const candidates = await prisma.agentInvocation.findMany({
264
- where: { status: 'RUNNING', sessionId: { not: null } },
329
+ where: { status: 'RUNNING', dispatchRevokedAt: null, sessionId: { not: null } },
265
330
  include: { teamRun: { select: { heartbeatTimeoutMinutes: true, task: { select: { deletedAt: true } } } } },
266
331
  });
267
332
  const failures = [];
268
333
  for (const invocation of candidates) {
269
334
  try {
270
- await this.reconcileOrphanCandidate(invocation);
335
+ const action = await this.reconcileOrphanCandidate(invocation);
336
+ if (action?.kind === 'terminal')
337
+ await this.finishStalledTerminal(action);
271
338
  }
272
339
  catch (error) {
273
340
  this.logCandidateFailure('orphan invocation', invocation.id, error);
@@ -288,6 +355,9 @@ export class TeamReconcilerService {
288
355
  const failures = [];
289
356
  for (const invocation of candidates) {
290
357
  try {
358
+ if (invocation.sessionId
359
+ && !await this.isSessionRuntimeCleanupConfirmed(invocation.sessionId))
360
+ continue;
291
361
  await this.afterInvocationTerminal(invocation.teamRunId, invocation.id);
292
362
  }
293
363
  catch (error) {
@@ -299,6 +369,235 @@ export class TeamReconcilerService {
299
369
  throw new AggregateError(failures, `Failed to reconcile ${failures.length} incomplete terminal invocation candidate(s)`);
300
370
  }
301
371
  }
372
+ async reconcilePendingRuntimeCleanup(limit = 50) {
373
+ const now = this.now();
374
+ const quarantineCount = await reportDueRuntimeCleanupQuarantines(limit, now);
375
+ const retryable = this.sessionMessenger?.retryRuntimeProcessCleanup
376
+ ? await prisma.executionProcess.findMany({
377
+ where: {
378
+ runtimeInstanceId: { not: null },
379
+ ownershipToken: { not: null },
380
+ cleanupState: { in: ['PENDING', 'FAILED'] },
381
+ OR: [
382
+ { nextCleanupRetryAt: null },
383
+ { nextCleanupRetryAt: { lte: now } },
384
+ ],
385
+ },
386
+ select: {
387
+ sessionId: true,
388
+ runtimeInstanceId: true,
389
+ launchClaimNumber: true,
390
+ pid: true,
391
+ processGroupId: true,
392
+ birthMarker: true,
393
+ ownershipToken: true,
394
+ cleanupState: true,
395
+ },
396
+ orderBy: [{ nextCleanupRetryAt: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
397
+ take: limit,
398
+ })
399
+ : [];
400
+ // ACTIVE belongs to a live runtime in the current server generation. An
401
+ // ACTIVE row without an in-memory owner is from an abrupt prior shutdown
402
+ // and must enter the same persisted recovery path.
403
+ const staleActive = this.sessionMessenger?.retryRuntimeProcessCleanup && retryable.length < limit
404
+ ? (await prisma.executionProcess.findMany({
405
+ where: {
406
+ runtimeInstanceId: { not: null },
407
+ ownershipToken: { not: null },
408
+ cleanupState: 'ACTIVE',
409
+ },
410
+ select: {
411
+ sessionId: true,
412
+ runtimeInstanceId: true,
413
+ launchClaimNumber: true,
414
+ pid: true,
415
+ processGroupId: true,
416
+ birthMarker: true,
417
+ ownershipToken: true,
418
+ cleanupState: true,
419
+ },
420
+ orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
421
+ take: limit - retryable.length,
422
+ })).filter((candidate) => (candidate.runtimeInstanceId
423
+ && Number.isInteger(candidate.launchClaimNumber)
424
+ && candidate.launchClaimNumber > 0
425
+ && this.sessionMessenger?.hasRuntimeProcessOwner != null
426
+ && !this.sessionMessenger.hasRuntimeProcessOwner(candidate.runtimeInstanceId, candidate.sessionId, candidate.launchClaimNumber)))
427
+ : [];
428
+ const candidates = [...retryable, ...staleActive];
429
+ const sessionMessenger = this.sessionMessenger;
430
+ const retryRuntimeProcessCleanup = sessionMessenger?.retryRuntimeProcessCleanup;
431
+ for (const candidate of candidates) {
432
+ if (!candidate.runtimeInstanceId
433
+ || !candidate.ownershipToken
434
+ || candidate.pid == null
435
+ || !Number.isInteger(candidate.launchClaimNumber)
436
+ || candidate.launchClaimNumber <= 0)
437
+ continue;
438
+ try {
439
+ if (!retryRuntimeProcessCleanup)
440
+ continue;
441
+ // Keep the messenger as the receiver. SessionManager's cleanup method
442
+ // updates its own durable state through `this`, so invoking a detached
443
+ // function here would turn every restart recovery into a TypeError.
444
+ await sessionMessenger.retryRuntimeProcessCleanup({
445
+ sessionId: candidate.sessionId,
446
+ runtimeInstanceId: candidate.runtimeInstanceId,
447
+ launchClaimNumber: candidate.launchClaimNumber,
448
+ pid: candidate.pid,
449
+ processGroupId: candidate.processGroupId,
450
+ birthMarker: candidate.birthMarker,
451
+ ownershipToken: candidate.ownershipToken,
452
+ });
453
+ const invocation = await prisma.agentInvocation.findFirst({
454
+ where: { sessionId: candidate.sessionId },
455
+ select: {
456
+ id: true,
457
+ teamRunId: true,
458
+ status: true,
459
+ dispatchRevokedAt: true,
460
+ },
461
+ });
462
+ if (!invocation)
463
+ continue;
464
+ if (invocation.dispatchRevokedAt
465
+ && ACTIVE_INVOCATION_STATUSES.includes(invocation.status)) {
466
+ const process = await prisma.executionProcess.findFirst({
467
+ where: {
468
+ sessionId: candidate.sessionId,
469
+ runtimeInstanceId: candidate.runtimeInstanceId,
470
+ launchClaimNumber: candidate.launchClaimNumber,
471
+ },
472
+ select: { cleanupState: true },
473
+ });
474
+ if (process?.cleanupState === 'CONFIRMED'
475
+ && await this.canTerminalizeRevokedInvocation(invocation.id, candidate.sessionId)) {
476
+ await this.terminalizeRevokedInvocation(invocation.id);
477
+ }
478
+ else {
479
+ await this.sessionMessenger?.stop?.(candidate.sessionId, { skipTeamRunReconcile: true });
480
+ if (await this.canTerminalizeRevokedInvocation(invocation.id, candidate.sessionId)) {
481
+ await this.terminalizeRevokedInvocation(invocation.id);
482
+ }
483
+ }
484
+ }
485
+ else if (TERMINAL_INVOCATION_STATUSES.includes(invocation.status)) {
486
+ await this.afterInvocationTerminal(invocation.teamRunId, invocation.id, candidate.runtimeInstanceId);
487
+ }
488
+ }
489
+ catch (error) {
490
+ this.logCandidateFailure('runtime cleanup', candidate.runtimeInstanceId, error);
491
+ }
492
+ }
493
+ // A tree can be confirmed immediately before a transient DB failure in
494
+ // terminalization. Such rows are no longer PENDING/FAILED, so scan
495
+ // confirmed ownership records for the revoked + active recovery case.
496
+ const confirmedCandidates = await this.findConfirmedRevokedCandidates(limit);
497
+ for (const candidate of confirmedCandidates) {
498
+ try {
499
+ if (await this.canTerminalizeRevokedInvocation(candidate.invocationId, candidate.sessionId)) {
500
+ await this.terminalizeRevokedInvocation(candidate.invocationId);
501
+ }
502
+ }
503
+ catch (error) {
504
+ this.logCandidateFailure('revoked terminalization', candidate.invocationId, error);
505
+ }
506
+ }
507
+ return candidates.length + quarantineCount;
508
+ }
509
+ async findConfirmedRevokedCandidates(limit) {
510
+ const results = [];
511
+ const pageSize = Math.max(50, limit);
512
+ let cursor;
513
+ while (results.length < limit) {
514
+ const invocations = await prisma.agentInvocation.findMany({
515
+ where: {
516
+ dispatchRevokedAt: { not: null },
517
+ status: { in: ACTIVE_INVOCATION_STATUSES },
518
+ sessionId: { not: null },
519
+ },
520
+ select: { id: true, sessionId: true },
521
+ orderBy: { id: 'asc' },
522
+ take: pageSize,
523
+ ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
524
+ });
525
+ for (const invocation of invocations) {
526
+ if (invocation.sessionId
527
+ && await this.canTerminalizeRevokedInvocation(invocation.id, invocation.sessionId)) {
528
+ results.push({ invocationId: invocation.id, sessionId: invocation.sessionId });
529
+ if (results.length >= limit)
530
+ break;
531
+ }
532
+ }
533
+ if (invocations.length < pageSize)
534
+ break;
535
+ cursor = invocations[invocations.length - 1].id;
536
+ }
537
+ return results;
538
+ }
539
+ async canTerminalizeRevokedInvocation(invocationId, sessionId) {
540
+ return this.canTerminalizeAfterRuntimeCleanup(invocationId, sessionId, true);
541
+ }
542
+ /** All terminal paths share the durable launch + ownership cleanup gate. */
543
+ async canTerminalizeAfterRuntimeCleanup(invocationId, sessionId, requireDispatchRevoked = false) {
544
+ if (!await this.isSessionRuntimeCleanupConfirmed(sessionId))
545
+ return false;
546
+ const invocation = await prisma.agentInvocation.findUnique({
547
+ where: { id: invocationId },
548
+ select: { status: true, dispatchRevokedAt: true, sessionId: true },
549
+ });
550
+ return invocation?.sessionId === sessionId
551
+ && (!requireDispatchRevoked || invocation.dispatchRevokedAt != null)
552
+ && ACTIVE_INVOCATION_STATUSES.includes(invocation.status);
553
+ }
554
+ async terminalizeRevokedInvocation(invocationId) {
555
+ const candidate = await prisma.agentInvocation.findUnique({
556
+ where: { id: invocationId },
557
+ select: { teamRunId: true, memberId: true, sessionId: true },
558
+ });
559
+ if (!candidate?.sessionId)
560
+ return [];
561
+ if (!await this.canTerminalizeAfterRuntimeCleanup(invocationId, candidate.sessionId, true))
562
+ return [];
563
+ const releaseAdmission = await acquireTeamMemberAdmission(candidate.teamRunId, candidate.memberId);
564
+ let result = null;
565
+ try {
566
+ if (!await this.canTerminalizeAfterRuntimeCleanup(invocationId, candidate.sessionId, true))
567
+ return [];
568
+ result = await prisma.$transaction(async (tx) => {
569
+ const invocation = await tx.agentInvocation.findUnique({
570
+ where: { id: invocationId },
571
+ select: { id: true, teamRunId: true, sessionId: true, workRequestId: true, status: true, dispatchRevokedAt: true },
572
+ });
573
+ if (!invocation
574
+ || !invocation.dispatchRevokedAt
575
+ || !ACTIVE_INVOCATION_STATUSES.includes(invocation.status))
576
+ return null;
577
+ const updated = await tx.agentInvocation.updateMany({
578
+ where: { id: invocation.id, status: { in: ACTIVE_INVOCATION_STATUSES }, dispatchRevokedAt: { not: null } },
579
+ data: { status: 'CANCELLED', nextRoomReplyReminderAt: null, roomReplyReminderCount: 0, firstNudgeAt: null },
580
+ });
581
+ if (updated.count !== 1)
582
+ return null;
583
+ if (invocation.sessionId) {
584
+ await tx.session.updateMany({
585
+ where: { id: invocation.sessionId, status: { notIn: ['COMPLETED', 'FAILED', 'CANCELLED'] } },
586
+ data: { status: 'CANCELLED' },
587
+ });
588
+ }
589
+ return { teamRunId: invocation.teamRunId };
590
+ });
591
+ }
592
+ finally {
593
+ releaseAdmission();
594
+ }
595
+ if (!result)
596
+ return [];
597
+ this.clearReminderTimer(invocationId);
598
+ await this.emitTeamRunInvalidated(result.teamRunId, ['agent-invocations', 'team-run'], 'agent-invocation-updated');
599
+ return this.afterInvocationTerminal(result.teamRunId, invocationId);
600
+ }
302
601
  async findIncompleteTerminalInvocationCandidates() {
303
602
  const startedWorkRequests = await prisma.workRequest.findMany({
304
603
  where: { status: 'STARTED' },
@@ -314,7 +613,7 @@ export class TeamReconcilerService {
314
613
  workRequestId: { in: workRequestIds },
315
614
  status: { in: [...ACTIVE_INVOCATION_STATUSES, ...TERMINAL_INVOCATION_STATUSES] },
316
615
  },
317
- select: { id: true, teamRunId: true, workRequestId: true, status: true },
616
+ select: { id: true, teamRunId: true, sessionId: true, workRequestId: true, status: true },
318
617
  orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
319
618
  });
320
619
  const activeWorkRequestIds = new Set();
@@ -337,21 +636,25 @@ export class TeamReconcilerService {
337
636
  return candidates;
338
637
  }
339
638
  async reconcileStalledCandidate(invocation, now) {
639
+ if (invocation.dispatchRevokedAt) {
640
+ return { kind: 'none' };
641
+ }
340
642
  if (isTaskDeleted(invocation.teamRun.task)) {
341
- return;
643
+ return { kind: 'none' };
342
644
  }
343
645
  const sessionId = invocation.sessionId;
344
646
  if (!sessionId) {
345
- return;
647
+ return { kind: 'none' };
346
648
  }
347
649
  // Permission waits are an active turn controlled by the user. They do not
348
650
  // count as agent silence and must not trigger an automated nudge.
349
651
  if (this.sessionMessenger?.isAwaitingPermission?.(sessionId)) {
350
- return;
652
+ return { kind: 'none' };
351
653
  }
352
- // 进程已不在内存管理中:交给首扫 orphan 或正常 exit 流程,避免运行期对刚退出瞬态的误判。
654
+ // 进程已不在内存管理中:仍需通过 persisted cleanup gate,避免把
655
+ // hasActiveTurn=false 误当成 owned process tree 已退出。
353
656
  if (!this.hasActiveTurn(sessionId)) {
354
- return;
657
+ return this.claimStalledTerminalUnderAdmission(invocation.id, sessionId, now);
355
658
  }
356
659
  const lastActivity = invocation.lastHeartbeatAt ?? invocation.createdAt;
357
660
  const idleMs = now.getTime() - lastActivity.getTime();
@@ -360,88 +663,184 @@ export class TeamReconcilerService {
360
663
  ?? (configuredThresholdMs > 0 ? configuredThresholdMs : DEFAULT_HEARTBEAT_IDLE_THRESHOLD_MS);
361
664
  if (idleMs < heartbeatIdleThresholdMs) {
362
665
  if (invocation.roomReplyReminderCount > 0 || invocation.nextRoomReplyReminderAt) {
363
- await prisma.agentInvocation.update({
364
- where: { id: invocation.id },
666
+ await prisma.agentInvocation.updateMany({
667
+ where: { id: invocation.id, status: 'RUNNING', dispatchRevokedAt: null },
365
668
  data: { roomReplyReminderCount: 0, nextRoomReplyReminderAt: null },
366
669
  });
367
670
  await this.emitTeamRunInvalidated(invocation.teamRunId, ['agent-invocations'], 'agent-invocation-updated');
368
671
  }
369
- return;
672
+ return { kind: 'none' };
370
673
  }
371
674
  if (invocation.firstNudgeAt && now.getTime() - invocation.firstNudgeAt.getTime() > this.absoluteNudgeBudgetMs) {
372
- await this.releaseStalledInvocation(invocation);
373
- return;
675
+ const claimed = await this.claimStalledRelease(invocation.id, now);
676
+ return claimed
677
+ ? { kind: 'terminal', teamRunId: invocation.teamRunId, memberId: invocation.memberId, invocationId: invocation.id, sessionId }
678
+ : { kind: 'none' };
374
679
  }
375
680
  if (invocation.roomReplyReminderCount >= this.maxRoomReplyReminders) {
376
- await this.releaseStalledInvocation(invocation);
377
- return;
681
+ const claimed = await this.claimStalledRelease(invocation.id, now);
682
+ return claimed
683
+ ? { kind: 'terminal', teamRunId: invocation.teamRunId, memberId: invocation.memberId, invocationId: invocation.id, sessionId }
684
+ : { kind: 'none' };
378
685
  }
379
686
  if (invocation.nextRoomReplyReminderAt && invocation.nextRoomReplyReminderAt.getTime() > now.getTime()) {
380
- return;
687
+ return { kind: 'none' };
381
688
  }
382
689
  const nextCount = invocation.roomReplyReminderCount + 1;
383
690
  const nextAt = this.addDelay(now, this.getReminderDelayMs(nextCount));
384
- await prisma.agentInvocation.update({
385
- where: { id: invocation.id },
691
+ const claimed = await prisma.agentInvocation.updateMany({
692
+ where: { id: invocation.id, status: 'RUNNING', dispatchRevokedAt: null },
386
693
  data: {
387
694
  roomReplyReminderCount: nextCount,
388
695
  nextRoomReplyReminderAt: nextAt,
389
696
  firstNudgeAt: invocation.firstNudgeAt ?? now,
390
697
  },
391
698
  });
699
+ if (claimed.count !== 1)
700
+ return { kind: 'none' };
392
701
  await this.emitTeamRunInvalidated(invocation.teamRunId, ['agent-invocations'], 'agent-invocation-updated');
393
- const nudgeSent = await this.sendHeartbeatNudge(sessionId);
394
- if (!nudgeSent && this.isSessionPipelineMissing(sessionId)) {
395
- const current = await prisma.agentInvocation.findUnique({
396
- where: { id: invocation.id },
397
- select: { id: true, teamRunId: true, sessionId: true, status: true },
398
- });
399
- if (current?.status === 'RUNNING') {
400
- await this.releaseStalledInvocation(current);
401
- }
402
- }
702
+ return { kind: 'nudge', sessionId, invocationId: invocation.id };
703
+ }
704
+ async claimStalledRelease(invocationId, now) {
705
+ const claimed = await prisma.agentInvocation.updateMany({
706
+ where: { id: invocationId, status: 'RUNNING', dispatchRevokedAt: null },
707
+ data: { dispatchRevokedAt: now, nextRoomReplyReminderAt: null },
708
+ });
709
+ return claimed.count === 1;
403
710
  }
404
711
  async reconcileOrphanCandidate(invocation) {
405
712
  if (isTaskDeleted(invocation.teamRun.task) || !invocation.sessionId) {
406
- return;
713
+ return { kind: 'none' };
407
714
  }
408
715
  const alive = this.hasActiveTurn(invocation.sessionId);
409
716
  if (!alive) {
410
- await this.releaseStalledInvocation(invocation);
717
+ const releaseAdmission = await acquireTeamMemberAdmission(invocation.teamRunId, invocation.memberId);
718
+ try {
719
+ return this.claimStalledTerminalUnderAdmission(invocation.id, invocation.sessionId, this.now());
720
+ }
721
+ finally {
722
+ releaseAdmission();
723
+ }
411
724
  }
725
+ return { kind: 'none' };
412
726
  }
413
727
  logCandidateFailure(kind, invocationId, error) {
414
728
  console.warn(`[TeamReconcilerService] Failed to reconcile ${kind} ${invocationId}:`, error instanceof Error ? error.message : error);
415
729
  }
416
- async releaseStalledInvocation(invocation) {
417
- const sessionId = invocation.sessionId;
418
- const alive = sessionId ? !this.isSessionPipelineMissing(sessionId) : false;
419
- // 进程仍存活:走 stop,由 handleSessionStopped 置 CANCELLED 并触发 afterInvocationTerminal 闭环。
420
- if (sessionId && alive && this.sessionMessenger?.stop) {
421
- this.clearReminderTimer(invocation.id);
422
- await this.sessionMessenger.stop(sessionId);
423
- return;
730
+ async claimStalledTerminal(invocationId, sessionId, now) {
731
+ const candidate = await prisma.agentInvocation.findUnique({
732
+ where: { id: invocationId },
733
+ select: { teamRunId: true, memberId: true },
734
+ });
735
+ if (!candidate)
736
+ return { kind: 'none' };
737
+ const releaseAdmission = await acquireTeamMemberAdmission(candidate.teamRunId, candidate.memberId);
738
+ try {
739
+ return await this.claimStalledTerminalUnderAdmission(invocationId, sessionId, now);
424
740
  }
425
- // 无存活进程(已退出 / orphan):直接置 FAILED 并手动走释放闭环。
426
- await prisma.agentInvocation.update({
427
- where: { id: invocation.id },
741
+ finally {
742
+ releaseAdmission();
743
+ }
744
+ }
745
+ async claimStalledTerminalUnderAdmission(invocationId, sessionId, now) {
746
+ const current = await prisma.agentInvocation.findUnique({
747
+ where: { id: invocationId },
748
+ select: { id: true, teamRunId: true, memberId: true, sessionId: true, status: true, dispatchRevokedAt: true },
749
+ });
750
+ if (!current || current.status !== 'RUNNING' || current.dispatchRevokedAt)
751
+ return { kind: 'none' };
752
+ if (!await this.canTerminalizeAfterRuntimeCleanup(invocationId, sessionId)) {
753
+ await prisma.agentInvocation.updateMany({
754
+ where: { id: invocationId, status: 'RUNNING', dispatchRevokedAt: null },
755
+ data: { dispatchRevokedAt: now, nextRoomReplyReminderAt: null },
756
+ });
757
+ return { kind: 'none' };
758
+ }
759
+ const failed = await prisma.agentInvocation.updateMany({
760
+ where: { id: invocationId, status: 'RUNNING', dispatchRevokedAt: null },
428
761
  data: {
429
762
  status: 'FAILED',
430
- roomReplyReminderCount: 0,
431
763
  nextRoomReplyReminderAt: null,
764
+ roomReplyReminderCount: 0,
432
765
  firstNudgeAt: null,
433
766
  },
434
767
  });
435
- await this.emitTeamRunInvalidated(invocation.teamRunId, ['agent-invocations', 'team-run'], 'agent-invocation-updated');
436
- this.clearReminderTimer(invocation.id);
437
- await this.afterInvocationTerminal(invocation.teamRunId, invocation.id);
768
+ if (failed.count !== 1)
769
+ return { kind: 'none' };
770
+ await prisma.session.updateMany({
771
+ where: { id: sessionId, status: { notIn: ['COMPLETED', 'FAILED', 'CANCELLED'] } },
772
+ data: { status: 'FAILED' },
773
+ });
774
+ return { kind: 'terminal', teamRunId: current.teamRunId, memberId: current.memberId, invocationId, sessionId };
438
775
  }
439
- async sendHeartbeatNudge(sessionId) {
776
+ async finishStalledTerminal(action) {
777
+ this.clearReminderTimer(action.invocationId);
778
+ const before = await prisma.agentInvocation.findUnique({
779
+ where: { id: action.invocationId },
780
+ select: { status: true, dispatchRevokedAt: true },
781
+ });
782
+ if (!before)
783
+ return;
784
+ if (ACTIVE_INVOCATION_STATUSES.includes(before.status)) {
785
+ if (action.sessionId && this.hasActiveTurn(action.sessionId) && this.sessionMessenger?.stop) {
786
+ await this.sessionMessenger.stop(action.sessionId, { skipTeamRunReconcile: true });
787
+ }
788
+ if (action.sessionId
789
+ && !await this.canTerminalizeAfterRuntimeCleanup(action.invocationId, action.sessionId, true)) {
790
+ return;
791
+ }
792
+ }
793
+ const releaseAdmission = await acquireTeamMemberAdmission(action.teamRunId, action.memberId);
794
+ let cancelled = false;
795
+ try {
796
+ const current = await prisma.agentInvocation.findUnique({
797
+ where: { id: action.invocationId },
798
+ select: { teamRunId: true, memberId: true, status: true, dispatchRevokedAt: true },
799
+ });
800
+ if (current?.status === 'FAILED') {
801
+ cancelled = true;
802
+ }
803
+ else if (current && current.dispatchRevokedAt && ACTIVE_INVOCATION_STATUSES.includes(current.status)) {
804
+ const updated = await prisma.agentInvocation.updateMany({
805
+ where: { id: action.invocationId, status: { in: ACTIVE_INVOCATION_STATUSES }, dispatchRevokedAt: { not: null } },
806
+ data: {
807
+ status: 'CANCELLED',
808
+ nextRoomReplyReminderAt: null,
809
+ roomReplyReminderCount: 0,
810
+ firstNudgeAt: null,
811
+ },
812
+ });
813
+ cancelled = updated.count === 1;
814
+ if (cancelled && action.sessionId) {
815
+ await prisma.session.updateMany({
816
+ where: { id: action.sessionId, status: { notIn: ['COMPLETED', 'FAILED', 'CANCELLED'] } },
817
+ data: { status: 'CANCELLED' },
818
+ });
819
+ }
820
+ }
821
+ }
822
+ finally {
823
+ releaseAdmission();
824
+ }
825
+ if (!cancelled)
826
+ return;
827
+ await this.emitTeamRunInvalidated(action.teamRunId, ['agent-invocations', 'team-run'], 'agent-invocation-updated');
828
+ await this.afterInvocationTerminal(action.teamRunId, action.invocationId);
829
+ }
830
+ async sendHeartbeatNudge(sessionId, invocationId) {
440
831
  if (!this.sessionMessenger) {
441
832
  return false;
442
833
  }
443
834
  try {
444
- await this.sessionMessenger.sendMessage(sessionId, TEAM_HEARTBEAT_NUDGE);
835
+ if (invocationId) {
836
+ const admitted = await prisma.agentInvocation.findFirst({
837
+ where: { id: invocationId, sessionId, status: 'RUNNING', dispatchRevokedAt: null },
838
+ select: { id: true },
839
+ });
840
+ if (!admitted)
841
+ return false;
842
+ }
843
+ await this.sessionMessenger.sendMessage(sessionId, TEAM_HEARTBEAT_NUDGE, undefined, invocationId);
445
844
  return true;
446
845
  }
447
846
  catch (error) {
@@ -470,7 +869,6 @@ export class TeamReconcilerService {
470
869
  if (!teamRun) {
471
870
  return false;
472
871
  }
473
- await this.releaseTerminalInvocationLocks(teamRun.invocations);
474
872
  if (teamRun.task.deletedAt) {
475
873
  return false;
476
874
  }
@@ -535,17 +933,43 @@ export class TeamReconcilerService {
535
933
  reason,
536
934
  }, this.eventBus);
537
935
  }
538
- async afterInvocationTerminal(teamRunId, invocationId) {
936
+ async afterInvocationTerminal(teamRunId, invocationId, expectedRuntimeInstanceId) {
937
+ const terminalInvocation = await prisma.agentInvocation.findUnique({
938
+ where: { id: invocationId },
939
+ select: {
940
+ sessionId: true,
941
+ status: true,
942
+ teamRun: { select: { task: { select: { deletedAt: true } } } },
943
+ },
944
+ });
945
+ if (!terminalInvocation || !TERMINAL_INVOCATION_STATUSES.includes(terminalInvocation.status))
946
+ return [];
947
+ if (terminalInvocation.sessionId) {
948
+ // TeamRun sessions are one invocation per Tower Session. Dispose only
949
+ // after the invocation is genuinely terminal; WAITING_ROOM_REPLY keeps
950
+ // the runtime open for the legal reminder/follow-up path.
951
+ await this.sessionMessenger?.disposeRuntimeSession?.(terminalInvocation.sessionId, expectedRuntimeInstanceId);
952
+ if (!await this.isSessionRuntimeCleanupConfirmed(terminalInvocation.sessionId))
953
+ return [];
954
+ }
955
+ // WorkRequest STARTED -> terminal is the unique durable winner for all
956
+ // after-terminal side effects. Repeated heartbeat/startup reconciliation is
957
+ // therefore idempotent once cleanup has been confirmed.
958
+ if (!await this.syncTerminalWorkRequest(invocationId))
959
+ return [];
539
960
  const scheduler = await this.getScheduler();
540
961
  scheduler.releaseInvocationLocks(invocationId);
541
- await this.syncTerminalWorkRequest(invocationId);
962
+ if (isTaskDeleted(terminalInvocation.teamRun.task))
963
+ return [];
964
+ let startedInvocations = [];
542
965
  try {
543
- await scheduler.startNextSessions(teamRunId);
966
+ startedInvocations = await scheduler.startNextSessions(teamRunId);
544
967
  }
545
968
  catch (error) {
546
969
  console.warn(`[TeamReconcilerService] Failed to start queued TeamRun work for ${teamRunId}:`, error instanceof Error ? error.message : error);
547
970
  }
548
971
  await this.maybeAdvanceTeamRunToReview(teamRunId);
972
+ return startedInvocations;
549
973
  }
550
974
  async syncTerminalWorkRequest(invocationId) {
551
975
  const invocation = await prisma.agentInvocation.findUnique({
@@ -553,19 +977,20 @@ export class TeamReconcilerService {
553
977
  select: { status: true, workRequestId: true },
554
978
  });
555
979
  if (!invocation) {
556
- return;
980
+ return false;
557
981
  }
558
982
  const terminalWorkRequestStatus = this.toTerminalWorkRequestStatus(invocation.status);
559
983
  if (!terminalWorkRequestStatus) {
560
- return;
984
+ return false;
561
985
  }
562
- await prisma.workRequest.updateMany({
986
+ const updated = await prisma.workRequest.updateMany({
563
987
  where: {
564
988
  id: invocation.workRequestId,
565
989
  status: 'STARTED',
566
990
  },
567
991
  data: { status: terminalWorkRequestStatus },
568
992
  });
993
+ return updated.count === 1;
569
994
  }
570
995
  toTerminalWorkRequestStatus(status) {
571
996
  if (status === 'COMPLETED' || status === 'FAILED' || status === 'CANCELLED') {
@@ -573,21 +998,13 @@ export class TeamReconcilerService {
573
998
  }
574
999
  return null;
575
1000
  }
576
- async releaseTerminalInvocationLocks(invocations) {
577
- const scheduler = await this.getScheduler();
578
- for (const invocation of invocations) {
579
- if (TERMINAL_INVOCATION_STATUSES.includes(invocation.status)) {
580
- scheduler.releaseInvocationLocks(invocation.id);
581
- }
582
- }
583
- }
584
- async sendRoomReplyReminder(sessionId) {
1001
+ async sendRoomReplyReminder(sessionId, invocationId) {
585
1002
  if (!this.sessionMessenger) {
586
1003
  console.warn(`[TeamReconcilerService] No session messenger configured for room reply reminder: ${sessionId}`);
587
1004
  return;
588
1005
  }
589
1006
  try {
590
- await this.sessionMessenger.sendMessage(sessionId, TEAM_ROOM_REPLY_REMINDER);
1007
+ await this.sessionMessenger.sendMessage(sessionId, TEAM_ROOM_REPLY_REMINDER, undefined, invocationId);
591
1008
  }
592
1009
  catch (error) {
593
1010
  console.warn(`[TeamReconcilerService] Failed to send room reply reminder to session ${sessionId}:`, error instanceof Error ? error.message : error);