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.
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +4 -0
- package/dist/app.js.map +1 -1
- package/dist/cli.js +39 -19
- package/dist/cli.js.map +1 -1
- package/dist/executors/__tests__/base.executor.test.js +133 -1
- package/dist/executors/__tests__/base.executor.test.js.map +1 -1
- package/dist/executors/__tests__/stdin-prompt.executors.test.js +18 -0
- package/dist/executors/__tests__/stdin-prompt.executors.test.js.map +1 -1
- package/dist/executors/base.executor.d.ts +8 -0
- package/dist/executors/base.executor.d.ts.map +1 -1
- package/dist/executors/base.executor.js +336 -56
- package/dist/executors/base.executor.js.map +1 -1
- package/dist/executors/index.d.ts +2 -1
- package/dist/executors/index.d.ts.map +1 -1
- package/dist/executors/index.js +1 -1
- package/dist/executors/index.js.map +1 -1
- package/dist/executors/start-error.d.ts +17 -0
- package/dist/executors/start-error.d.ts.map +1 -1
- package/dist/executors/start-error.js +36 -1
- package/dist/executors/start-error.js.map +1 -1
- package/dist/index.js +39 -29
- package/dist/index.js.map +1 -1
- package/dist/mcp/http-client.test.js +20 -0
- package/dist/mcp/http-client.test.js.map +1 -1
- package/dist/mcp/tools/__tests__/sessions.test.d.ts +2 -0
- package/dist/mcp/tools/__tests__/sessions.test.d.ts.map +1 -0
- package/dist/mcp/tools/__tests__/sessions.test.js +33 -0
- package/dist/mcp/tools/__tests__/sessions.test.js.map +1 -0
- package/dist/routes/__tests__/sessions-runtime.test.js +75 -3
- package/dist/routes/__tests__/sessions-runtime.test.js.map +1 -1
- package/dist/routes/conversations.d.ts.map +1 -1
- package/dist/routes/conversations.js +5 -1
- package/dist/routes/conversations.js.map +1 -1
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +13 -2
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/team-runs.d.ts.map +1 -1
- package/dist/routes/team-runs.js +13 -1
- package/dist/routes/team-runs.js.map +1 -1
- package/dist/runtime/__tests__/acp-driver-lifecycle.test.js +86 -0
- package/dist/runtime/__tests__/acp-driver-lifecycle.test.js.map +1 -1
- package/dist/runtime/__tests__/acp-process-manager.test.js +393 -6
- package/dist/runtime/__tests__/acp-process-manager.test.js.map +1 -1
- package/dist/runtime/__tests__/cli-driver-lifecycle.test.d.ts +2 -0
- package/dist/runtime/__tests__/cli-driver-lifecycle.test.d.ts.map +1 -0
- package/dist/runtime/__tests__/cli-driver-lifecycle.test.js +244 -0
- package/dist/runtime/__tests__/cli-driver-lifecycle.test.js.map +1 -0
- package/dist/runtime/__tests__/launch-cleanup-registry.test.d.ts +2 -0
- package/dist/runtime/__tests__/launch-cleanup-registry.test.d.ts.map +1 -0
- package/dist/runtime/__tests__/launch-cleanup-registry.test.js +154 -0
- package/dist/runtime/__tests__/launch-cleanup-registry.test.js.map +1 -0
- package/dist/runtime/__tests__/runtime-coordinator.test.js +117 -1
- package/dist/runtime/__tests__/runtime-coordinator.test.js.map +1 -1
- package/dist/runtime/__tests__/server-entry-shutdown.test.d.ts +2 -0
- package/dist/runtime/__tests__/server-entry-shutdown.test.d.ts.map +1 -0
- package/dist/runtime/__tests__/server-entry-shutdown.test.js +38 -0
- package/dist/runtime/__tests__/server-entry-shutdown.test.js.map +1 -0
- package/dist/runtime/__tests__/shutdown-coordinator.test.d.ts +2 -0
- package/dist/runtime/__tests__/shutdown-coordinator.test.d.ts.map +1 -0
- package/dist/runtime/__tests__/shutdown-coordinator.test.js +28 -0
- package/dist/runtime/__tests__/shutdown-coordinator.test.js.map +1 -0
- package/dist/runtime/acp/acp-driver.d.ts.map +1 -1
- package/dist/runtime/acp/acp-driver.js +108 -35
- package/dist/runtime/acp/acp-driver.js.map +1 -1
- package/dist/runtime/acp/agents/managed-directory.d.ts.map +1 -1
- package/dist/runtime/acp/agents/managed-directory.js +3 -1
- package/dist/runtime/acp/agents/managed-directory.js.map +1 -1
- package/dist/runtime/acp/agents/managed-directory.test.d.ts +2 -0
- package/dist/runtime/acp/agents/managed-directory.test.d.ts.map +1 -0
- package/dist/runtime/acp/agents/managed-directory.test.js +25 -0
- package/dist/runtime/acp/agents/managed-directory.test.js.map +1 -0
- package/dist/runtime/acp/launch-cleanup-registry.d.ts +32 -0
- package/dist/runtime/acp/launch-cleanup-registry.d.ts.map +1 -0
- package/dist/runtime/acp/launch-cleanup-registry.js +176 -0
- package/dist/runtime/acp/launch-cleanup-registry.js.map +1 -0
- package/dist/runtime/acp/process-manager.d.ts +58 -0
- package/dist/runtime/acp/process-manager.d.ts.map +1 -1
- package/dist/runtime/acp/process-manager.js +489 -33
- package/dist/runtime/acp/process-manager.js.map +1 -1
- package/dist/runtime/cli-driver.d.ts.map +1 -1
- package/dist/runtime/cli-driver.js +230 -46
- package/dist/runtime/cli-driver.js.map +1 -1
- package/dist/runtime/contracts.d.ts +21 -2
- package/dist/runtime/contracts.d.ts.map +1 -1
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/runtime-coordinator.d.ts +8 -1
- package/dist/runtime/runtime-coordinator.d.ts.map +1 -1
- package/dist/runtime/runtime-coordinator.js +118 -18
- package/dist/runtime/runtime-coordinator.js.map +1 -1
- package/dist/runtime/server-entry-shutdown.d.ts +12 -0
- package/dist/runtime/server-entry-shutdown.d.ts.map +1 -0
- package/dist/runtime/server-entry-shutdown.js +23 -0
- package/dist/runtime/server-entry-shutdown.js.map +1 -0
- package/dist/runtime/shutdown-coordinator.d.ts +17 -0
- package/dist/runtime/shutdown-coordinator.d.ts.map +1 -0
- package/dist/runtime/shutdown-coordinator.js +67 -0
- package/dist/runtime/shutdown-coordinator.js.map +1 -0
- package/dist/services/__tests__/conversation.service.test.js +160 -0
- package/dist/services/__tests__/conversation.service.test.js.map +1 -1
- package/dist/services/__tests__/database-maintenance.service.test.js +97 -0
- package/dist/services/__tests__/database-maintenance.service.test.js.map +1 -1
- package/dist/services/__tests__/member-heartbeat-scheduler.test.js +6 -0
- package/dist/services/__tests__/member-heartbeat-scheduler.test.js.map +1 -1
- package/dist/services/__tests__/member-heartbeat.test.js +7 -11
- package/dist/services/__tests__/member-heartbeat.test.js.map +1 -1
- package/dist/services/__tests__/session-manager.lifecycle.test.js +402 -12
- package/dist/services/__tests__/session-manager.lifecycle.test.js.map +1 -1
- package/dist/services/__tests__/session-manager.team-run.test.js +422 -29
- package/dist/services/__tests__/session-manager.team-run.test.js.map +1 -1
- package/dist/services/__tests__/team-queue-recovery.integration.test.js +1 -0
- package/dist/services/__tests__/team-queue-recovery.integration.test.js.map +1 -1
- package/dist/services/__tests__/team-reconciler.service.test.js +1282 -4
- package/dist/services/__tests__/team-reconciler.service.test.js.map +1 -1
- package/dist/services/__tests__/team-run.service.test.js +161 -1
- package/dist/services/__tests__/team-run.service.test.js.map +1 -1
- package/dist/services/__tests__/team-scheduler.service.test.js +334 -6
- package/dist/services/__tests__/team-scheduler.service.test.js.map +1 -1
- package/dist/services/conversation.service.d.ts.map +1 -1
- package/dist/services/conversation.service.js +21 -2
- package/dist/services/conversation.service.js.map +1 -1
- package/dist/services/database-maintenance.service.d.ts.map +1 -1
- package/dist/services/database-maintenance.service.js +68 -17
- package/dist/services/database-maintenance.service.js.map +1 -1
- package/dist/services/member-heartbeat-scheduler.d.ts +6 -5
- package/dist/services/member-heartbeat-scheduler.d.ts.map +1 -1
- package/dist/services/member-heartbeat-scheduler.js +7 -5
- package/dist/services/member-heartbeat-scheduler.js.map +1 -1
- package/dist/services/session-manager.d.ts +124 -14
- package/dist/services/session-manager.d.ts.map +1 -1
- package/dist/services/session-manager.js +1035 -116
- package/dist/services/session-manager.js.map +1 -1
- package/dist/services/session-runtime-cleanup-gate.d.ts +25 -0
- package/dist/services/session-runtime-cleanup-gate.d.ts.map +1 -0
- package/dist/services/session-runtime-cleanup-gate.js +284 -0
- package/dist/services/session-runtime-cleanup-gate.js.map +1 -0
- package/dist/services/task.service.d.ts +2 -2
- package/dist/services/team-member-admission-barrier.d.ts +3 -0
- package/dist/services/team-member-admission-barrier.d.ts.map +1 -0
- package/dist/services/team-member-admission-barrier.js +26 -0
- package/dist/services/team-member-admission-barrier.js.map +1 -0
- package/dist/services/team-reconciler.service.d.ts +30 -7
- package/dist/services/team-reconciler.service.d.ts.map +1 -1
- package/dist/services/team-reconciler.service.js +528 -111
- package/dist/services/team-reconciler.service.js.map +1 -1
- package/dist/services/team-run.service.d.ts.map +1 -1
- package/dist/services/team-run.service.js +37 -13
- package/dist/services/team-run.service.js.map +1 -1
- package/dist/services/team-scheduler.service.d.ts +8 -6
- package/dist/services/team-scheduler.service.d.ts.map +1 -1
- package/dist/services/team-scheduler.service.js +323 -120
- package/dist/services/team-scheduler.service.js.map +1 -1
- package/dist/services/workspace-background-process-manager.d.ts.map +1 -1
- package/dist/services/workspace-background-process-manager.js +2 -5
- package/dist/services/workspace-background-process-manager.js.map +1 -1
- package/dist/services/workspace.service.d.ts +15 -8
- package/dist/services/workspace.service.d.ts.map +1 -1
- package/dist/socket/index.d.ts.map +1 -1
- package/dist/socket/index.js +27 -14
- package/dist/socket/index.js.map +1 -1
- package/dist/utils/__tests__/unix-process-identity.test.d.ts +2 -0
- package/dist/utils/__tests__/unix-process-identity.test.d.ts.map +1 -0
- package/dist/utils/__tests__/unix-process-identity.test.js +73 -0
- package/dist/utils/__tests__/unix-process-identity.test.js.map +1 -0
- package/dist/utils/error-log.d.ts +2 -0
- package/dist/utils/error-log.d.ts.map +1 -1
- package/dist/utils/error-log.js +40 -3
- package/dist/utils/error-log.js.map +1 -1
- package/dist/utils/error-log.test.js +14 -2
- package/dist/utils/error-log.test.js.map +1 -1
- package/dist/utils/process-launch.d.ts +2 -2
- package/dist/utils/process-launch.d.ts.map +1 -1
- package/dist/utils/process-launch.js +339 -79
- package/dist/utils/process-launch.js.map +1 -1
- package/dist/utils/process-launch.test.js +168 -10
- package/dist/utils/process-launch.test.js.map +1 -1
- package/dist/utils/spawned-process-identity.d.ts +7 -0
- package/dist/utils/spawned-process-identity.d.ts.map +1 -0
- package/dist/utils/spawned-process-identity.js +36 -0
- package/dist/utils/spawned-process-identity.js.map +1 -0
- package/dist/utils/tree-cleanup-channel.d.ts +18 -0
- package/dist/utils/tree-cleanup-channel.d.ts.map +1 -0
- package/dist/utils/tree-cleanup-channel.js +16 -0
- package/dist/utils/tree-cleanup-channel.js.map +1 -0
- package/dist/utils/tree-cleanup-channel.test.d.ts +2 -0
- package/dist/utils/tree-cleanup-channel.test.d.ts.map +1 -0
- package/dist/utils/tree-cleanup-channel.test.js +15 -0
- package/dist/utils/tree-cleanup-channel.test.js.map +1 -0
- package/dist/utils/unix-process-identity.d.ts +13 -1
- package/dist/utils/unix-process-identity.d.ts.map +1 -1
- package/dist/utils/unix-process-identity.js +146 -25
- package/dist/utils/unix-process-identity.js.map +1 -1
- package/node_modules/@agent-tower/shared/dist/types.d.ts +1 -0
- package/node_modules/@agent-tower/shared/dist/types.d.ts.map +1 -1
- package/node_modules/@agent-tower/shared/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260824000000_add_execution_process_ownership/migration.sql +10 -0
- package/prisma/migrations/20260824010000_add_runtime_cleanup_recovery/migration.sql +10 -0
- package/prisma/migrations/20260824020000_add_runtime_launch_admission/migration.sql +72 -0
- package/prisma/migrations/20260826000000_add_conversation_turn_queue/migration.sql +19 -0
- 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
|
|
80
|
+
return [];
|
|
77
81
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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(
|
|
97
|
-
return
|
|
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
|
|
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.
|
|
126
|
-
where: {
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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.
|
|
147
|
-
where: {
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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.
|
|
163
|
-
where: {
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
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
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
await
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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);
|