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