agent-tower 0.6.0-beta.13 → 0.6.0-beta.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/executors/default-providers.json +1 -0
- package/dist/runtime/__tests__/acp-driver-lifecycle.test.js +232 -6
- package/dist/runtime/__tests__/acp-driver-lifecycle.test.js.map +1 -1
- package/dist/runtime/__tests__/acp-driver.test.js +24 -0
- package/dist/runtime/__tests__/acp-driver.test.js.map +1 -1
- package/dist/runtime/__tests__/runtime-coordinator.test.js +205 -7
- package/dist/runtime/__tests__/runtime-coordinator.test.js.map +1 -1
- package/dist/runtime/acp/acp-driver.d.ts.map +1 -1
- package/dist/runtime/acp/acp-driver.js +188 -61
- package/dist/runtime/acp/acp-driver.js.map +1 -1
- package/dist/runtime/acp/codex-provider-config.d.ts.map +1 -1
- package/dist/runtime/acp/codex-provider-config.js +4 -0
- package/dist/runtime/acp/codex-provider-config.js.map +1 -1
- package/dist/runtime/contracts.d.ts +12 -0
- package/dist/runtime/contracts.d.ts.map +1 -1
- package/dist/runtime/runtime-coordinator.d.ts +22 -1
- package/dist/runtime/runtime-coordinator.d.ts.map +1 -1
- package/dist/runtime/runtime-coordinator.js +214 -26
- package/dist/runtime/runtime-coordinator.js.map +1 -1
- package/dist/services/__tests__/session-manager.lifecycle.test.js +578 -68
- package/dist/services/__tests__/session-manager.lifecycle.test.js.map +1 -1
- package/dist/services/__tests__/session-manager.team-run.test.js +2 -1
- package/dist/services/__tests__/session-manager.team-run.test.js.map +1 -1
- package/dist/services/session-manager.d.ts +14 -8
- package/dist/services/session-manager.d.ts.map +1 -1
- package/dist/services/session-manager.js +359 -176
- package/dist/services/session-manager.js.map +1 -1
- package/dist/web/assets/{AgentDemoPage-aLGw8pYk.js → AgentDemoPage-04hKRdNk.js} +1 -1
- package/dist/web/assets/{AgentEnvironmentSettingsPage-BYcH_wMz.js → AgentEnvironmentSettingsPage-TnYB_gn_.js} +1 -1
- package/dist/web/assets/{AgentLogo-Dyqxcj5s.js → AgentLogo-BQ4AhPCM.js} +1 -1
- package/dist/web/assets/ConversationPage-GSAFcHuX.js +3 -0
- package/dist/web/assets/CreateTaskInput-DSl73Wwc.js +1 -0
- package/dist/web/assets/{DemoPage-m92AArTk.js → DemoPage-BKlcChAw.js} +1 -1
- package/dist/web/assets/{GeneralSettingsPage-P5HcwCXo.js → GeneralSettingsPage-sVFvK9Oe.js} +1 -1
- package/dist/web/assets/{Icons-BFOphLw-.js → Icons-DGahMuvC.js} +1 -1
- package/dist/web/assets/{LoadingPreviewPage-CfT4FIyb.js → LoadingPreviewPage-CZ2N3qZc.js} +1 -1
- package/dist/web/assets/{McpSettingsPage-BI_b78L_.js → McpSettingsPage-KZFzkxCs.js} +1 -1
- package/dist/web/assets/{MemberAvatar-1UlgMZMM.js → MemberAvatar-5bEbGww5.js} +1 -1
- package/dist/web/assets/{NotificationSettingsPage-CQCyq9QZ.js → NotificationSettingsPage-hVClKCOc.js} +1 -1
- package/dist/web/assets/{ProfileSettingsPage-ojhJXLWb.js → ProfileSettingsPage-BICdSvU4.js} +1 -1
- package/dist/web/assets/{ProjectKanbanPage-DK3rZJzY.js → ProjectKanbanPage-D62MacD4.js} +27 -27
- package/dist/web/assets/{ProjectSettingsPage-MGB631ia.js → ProjectSettingsPage-aMerK2A7.js} +1 -1
- package/dist/web/assets/{ProviderSettingsPage-CMZvEWMa.js → ProviderSettingsPage-Djd3iQfB.js} +1 -1
- package/dist/web/assets/{SettingsMasterDetail-9CnCBuNZ.js → SettingsMasterDetail-Czojx5vD.js} +1 -1
- package/dist/web/assets/{TeamSettingsPage-BUXKd5UF.js → TeamSettingsPage-B3-4MXXx.js} +1 -1
- package/dist/web/assets/{arc-2leUEhVa.js → arc-DzPiJSKL.js} +1 -1
- package/dist/web/assets/{architectureDiagram-3BPJPVTR-CDgYR44L.js → architectureDiagram-3BPJPVTR-EhKxHp4t.js} +1 -1
- package/dist/web/assets/{arrow-left-Jn-v17JF.js → arrow-left-2dTz1a0q.js} +1 -1
- package/dist/web/assets/{blockDiagram-GPEHLZMM-CzfIh31g.js → blockDiagram-GPEHLZMM-BHcWmA7M.js} +1 -1
- package/dist/web/assets/{c4Diagram-AAUBKEIU-BzzBXKEF.js → c4Diagram-AAUBKEIU-D59zNlIS.js} +1 -1
- package/dist/web/assets/channel-BpGJqMDu.js +1 -0
- package/dist/web/assets/check-CYQXIceH.js +1 -0
- package/dist/web/assets/{chevron-down-CpKODb7Y.js → chevron-down-BTGC8Sed.js} +1 -1
- package/dist/web/assets/{chevron-right-CBoUSXVq.js → chevron-right-BEw6jjbJ.js} +1 -1
- package/dist/web/assets/{chevron-up-KZ1hsL8y.js → chevron-up-D_Hmx6ug.js} +1 -1
- package/dist/web/assets/{chunk-2J33WTMH-GJE6LCGS.js → chunk-2J33WTMH-nk1Ieiq7.js} +1 -1
- package/dist/web/assets/{chunk-4BX2VUAB-Bw_ITp1Q.js → chunk-4BX2VUAB-xl3tQkpA.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-DQ-XuWbD.js → chunk-55IACEB6-Bj12k-M-.js} +1 -1
- package/dist/web/assets/{chunk-727SXJPM-BgL49Dtw.js → chunk-727SXJPM-DeL4hVho.js} +1 -1
- package/dist/web/assets/{chunk-AQP2D5EJ-dRTVohoy.js → chunk-AQP2D5EJ-xyt_hZ7N.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-BZtRjoH7.js → chunk-FMBD7UC4-DMpougEb.js} +1 -1
- package/dist/web/assets/{chunk-ND2GUHAM-CN3wND6y.js → chunk-ND2GUHAM-D2qjylxW.js} +1 -1
- package/dist/web/assets/{chunk-QZHKN3VN-CMwbobMj.js → chunk-QZHKN3VN-BjCtAePb.js} +1 -1
- package/dist/web/assets/{circle-alert-kELlE07n.js → circle-alert-BGbIBYEs.js} +1 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-M-OAZSzx.js +1 -0
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-M-OAZSzx.js +1 -0
- package/dist/web/assets/{code-block-OCS4YCEC-B1YC7dLa.js → code-block-OCS4YCEC-DnfSnuHM.js} +1 -1
- package/dist/web/assets/{confirm-dialog-DDWlvtJs.js → confirm-dialog-C8rPqdFE.js} +1 -1
- package/dist/web/assets/{cose-bilkent-S5V4N54A-C6xD7kmY.js → cose-bilkent-S5V4N54A-DD3H-OxN.js} +1 -1
- package/dist/web/assets/{cssMode-lB5vNGvS.js → cssMode-DR6Jf3-g.js} +1 -1
- package/dist/web/assets/{dagre-BM42HDAG-BqTCYMO6.js → dagre-BM42HDAG-Bw6aZrG9.js} +1 -1
- package/dist/web/assets/{diagram-2AECGRRQ-BsMq3s5l.js → diagram-2AECGRRQ-Bm8oLz0N.js} +1 -1
- package/dist/web/assets/{diagram-5GNKFQAL-l4izpv7Z.js → diagram-5GNKFQAL-CL9Xaz3k.js} +1 -1
- package/dist/web/assets/{diagram-KO2AKTUF-uWDq6Yzo.js → diagram-KO2AKTUF-KbdYV5XJ.js} +1 -1
- package/dist/web/assets/{diagram-LMA3HP47-Dbd4fnFa.js → diagram-LMA3HP47-CYo7CoGp.js} +1 -1
- package/dist/web/assets/{diagram-OG6HWLK6-BC-27mkr.js → diagram-OG6HWLK6-CFPSh1Sm.js} +1 -1
- package/dist/web/assets/{erDiagram-TEJ5UH35-DrCP0Xt8.js → erDiagram-TEJ5UH35-Dh9qfvJz.js} +1 -1
- package/dist/web/assets/{flowDiagram-I6XJVG4X-BqNsQuUr.js → flowDiagram-I6XJVG4X-8EMyvj0V.js} +1 -1
- package/dist/web/assets/{folder-picker-CBVEWiuS.js → folder-picker-iHGLWmJF.js} +1 -1
- package/dist/web/assets/{freemarker2-Bos_2jdw.js → freemarker2-Dhyh-DXo.js} +1 -1
- package/dist/web/assets/{ganttDiagram-6RSMTGT7-CZOrG-5M.js → ganttDiagram-6RSMTGT7-BE5FBwwf.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-PVQCEYII-Dp3i5ehs.js → gitGraphDiagram-PVQCEYII-Dx2x_Y6A.js} +1 -1
- package/dist/web/assets/{handlebars-C7Elz3nH.js → handlebars-BB4WaAAq.js} +1 -1
- package/dist/web/assets/{html-EvsHOk5n.js → html-BluMUCI1.js} +1 -1
- package/dist/web/assets/{htmlMode-CTlhNZgJ.js → htmlMode-Dk0meNHs.js} +1 -1
- package/dist/web/assets/{index-hPp73HMS.js → index-9oo7US_J.js} +4 -4
- package/dist/web/assets/{index-BGajgi5H.js → index-B0gHfTIK.js} +3 -3
- package/dist/web/assets/{infoDiagram-5YYISTIA-C6NnR_Zb.js → infoDiagram-5YYISTIA-0BPLGHMf.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-C1SOf-Ko.js → ishikawaDiagram-YF4QCWOH-BnoV5etH.js} +1 -1
- package/dist/web/assets/javascript-Cwz9LBET.js +1 -0
- package/dist/web/assets/{journeyDiagram-JHISSGLW--GKMSbPz.js → journeyDiagram-JHISSGLW-D8_dQMoV.js} +1 -1
- package/dist/web/assets/{jsonMode-Bbh3Gbb8.js → jsonMode-DWAIY3Zm.js} +1 -1
- package/dist/web/assets/{kanban-definition-UN3LZRKU-Car5iy0R.js → kanban-definition-UN3LZRKU-CcP-UBWq.js} +1 -1
- package/dist/web/assets/{layers-WOwm63Fo.js → layers-B4Hb2k1E.js} +1 -1
- package/dist/web/assets/{linear-ChUj9SvT.js → linear-CFeIj_93.js} +1 -1
- package/dist/web/assets/{liquid-BXFWITFq.js → liquid-DszhNRew.js} +1 -1
- package/dist/web/assets/{lspLanguageFeatures-DQ2CrOBI.js → lspLanguageFeatures-DyajWL04.js} +1 -1
- package/dist/web/assets/{mdx-vHLpslzI.js → mdx-D3Dg3IU3.js} +1 -1
- package/dist/web/assets/{mermaid-NOHMQCX5-CzwPLFrI.js → mermaid-NOHMQCX5-De7jApSL.js} +4 -4
- package/dist/web/assets/{message-square-DgdFa1Kc.js → message-square-Dly_bein.js} +1 -1
- package/dist/web/assets/{mindmap-definition-RKZ34NQL-CfzbnHY_.js → mindmap-definition-RKZ34NQL-BBl0Q2SW.js} +1 -1
- package/dist/web/assets/{modal-QnCJhV9P.js → modal-CyT9BpRm.js} +1 -1
- package/dist/web/assets/{monaco-CbFQByO7.js → monaco-DBlH7Gvr.js} +3 -3
- package/dist/web/assets/{pencil-CNVU3zfM.js → pencil-BLlfV3fH.js} +1 -1
- package/dist/web/assets/{pieDiagram-4H26LBE5-CDqYJneU.js → pieDiagram-4H26LBE5-BVrsUXUt.js} +1 -1
- package/dist/web/assets/{python-BhFSY_J2.js → python-C1GF9JX6.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-W4KKPZXB-B6ChDIx2.js → quadrantDiagram-W4KKPZXB-Brnd7Z-x.js} +1 -1
- package/dist/web/assets/{razor-Cx_DrE-V.js → razor-CNwR1Ppb.js} +1 -1
- package/dist/web/assets/{requirementDiagram-4Y6WPE33-DefLT9NZ.js → requirementDiagram-4Y6WPE33-BEL5JUv5.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-5OEKKPKP-CQHnovPz.js → sankeyDiagram-5OEKKPKP-Dg3pq8MF.js} +1 -1
- package/dist/web/assets/{select-CmbeAAVh.js → select-BQuOHpgx.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-3UESZ5HK-DZuaKm7C.js → sequenceDiagram-3UESZ5HK-DvXxqwal.js} +1 -1
- package/dist/web/assets/{server-BUivqMYo.js → server-DmNZ4TcQ.js} +1 -1
- package/dist/web/assets/{stateDiagram-AJRCARHV-DvxpChEC.js → stateDiagram-AJRCARHV-BSXeM4wR.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-Cjr-lS2a.js +1 -0
- package/dist/web/assets/{switch-CiOyM13e.js → switch-BDzVWx67.js} +1 -1
- package/dist/web/assets/{textarea-D4HkYa3u.js → textarea-D4CrProF.js} +1 -1
- package/dist/web/assets/{timeline-definition-PNZ67QCA-BRu-tS-p.js → timeline-definition-PNZ67QCA-3rH0QWp7.js} +1 -1
- package/dist/web/assets/{trash-2-DPxBnxJM.js → trash-2-DHu1aC-u.js} +1 -1
- package/dist/web/assets/{tsMode-CHcZwUmC.js → tsMode-Z5KzKNgl.js} +1 -1
- package/dist/web/assets/{typescript-CvtjMxoq.js → typescript-Cg8IFK7o.js} +1 -1
- package/dist/web/assets/{upload-YmLtnZsU.js → upload-DQPwfdPh.js} +1 -1
- package/dist/web/assets/{use-profiles-OJAwfc90.js → use-profiles-DI5-xqHU.js} +1 -1
- package/dist/web/assets/{use-projects-iob5C7Xk.js → use-projects-Cs0-FMed.js} +1 -1
- package/dist/web/assets/{use-providers-Cj9S9_2q.js → use-providers-BoVFTPYO.js} +1 -1
- package/dist/web/assets/{useNormalizedLogs-BhN4AN4f.js → useNormalizedLogs-DhoOmBJo.js} +1 -1
- package/dist/web/assets/{vennDiagram-CIIHVFJN-CtZ_3Ld0.js → vennDiagram-CIIHVFJN-DAjUhsg9.js} +1 -1
- package/dist/web/assets/{wardley-L42UT6IY-h9yfTmIn.js → wardley-L42UT6IY-Db8PSpCZ.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-YWT4CUSO-CWXtZtix.js → wardleyDiagram-YWT4CUSO-DfXfIOXM.js} +1 -1
- package/dist/web/assets/{xml-DhyGRwiG.js → xml-UOLWFxtD.js} +1 -1
- package/dist/web/assets/{xychartDiagram-2RQKCTM6-BLuWVWlL.js → xychartDiagram-2RQKCTM6-C41mtVgh.js} +1 -1
- package/dist/web/assets/{yaml-Cpi6SvoR.js → yaml-CeY5SySy.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +2 -2
- package/dist/web/assets/ConversationPage-BBnK1qA_.js +0 -3
- package/dist/web/assets/CreateTaskInput-B11wGhDW.js +0 -1
- package/dist/web/assets/channel-XuYe6lH7.js +0 -1
- package/dist/web/assets/check-FwR4dMNY.js +0 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-DWSNJHiT.js +0 -1
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-DWSNJHiT.js +0 -1
- package/dist/web/assets/javascript-Desq1P0G.js +0 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-BbrRYMxK.js +0 -1
|
@@ -151,6 +151,121 @@ function waitForEvent(eventBus, event, timeoutMs = 5000) {
|
|
|
151
151
|
});
|
|
152
152
|
});
|
|
153
153
|
}
|
|
154
|
+
async function createRetryableAcpStopFixture(closeFailureMessages) {
|
|
155
|
+
const provider = {
|
|
156
|
+
id: 'cleanup-retry-acp-provider',
|
|
157
|
+
name: 'Cleanup Retry ACP Provider',
|
|
158
|
+
agentType: AgentType.CODEX,
|
|
159
|
+
runtimeType: RuntimeType.ACP,
|
|
160
|
+
env: {},
|
|
161
|
+
config: {},
|
|
162
|
+
isDefault: false,
|
|
163
|
+
};
|
|
164
|
+
getProviderByIdMock.mockReturnValue(provider);
|
|
165
|
+
const { workspace, session } = await createSessionFixture({ providerId: provider.id });
|
|
166
|
+
await prisma.session.update({
|
|
167
|
+
where: { id: session.id },
|
|
168
|
+
data: { runtimeType: RuntimeType.ACP },
|
|
169
|
+
});
|
|
170
|
+
const service = new WorkspaceBackgroundService();
|
|
171
|
+
const turns = [];
|
|
172
|
+
const observedCredentials = [];
|
|
173
|
+
const driverSessions = [];
|
|
174
|
+
const remainingCloseFailures = [...closeFailureMessages];
|
|
175
|
+
const capabilities = {
|
|
176
|
+
loadSession: true,
|
|
177
|
+
terminalInput: false,
|
|
178
|
+
terminalResize: false,
|
|
179
|
+
permissions: true,
|
|
180
|
+
};
|
|
181
|
+
const driver = {
|
|
182
|
+
type: RuntimeType.ACP,
|
|
183
|
+
open: vi.fn(async (input, openingSink) => {
|
|
184
|
+
const generation = driverSessions.length + 1;
|
|
185
|
+
const generationTurns = [];
|
|
186
|
+
const driverSession = {
|
|
187
|
+
runtimeInstanceId: `cleanup-retry-runtime-${generation}`,
|
|
188
|
+
capabilities,
|
|
189
|
+
externalSessionId: 'cleanup-retry-external',
|
|
190
|
+
runTurn: vi.fn(async (_turn, sink) => {
|
|
191
|
+
const credential = input.env.get(AGENT_API_CREDENTIAL_ENV) ?? '';
|
|
192
|
+
observedCredentials.push(credential);
|
|
193
|
+
const identity = validateAgentApiCredential(credential);
|
|
194
|
+
if (!identity)
|
|
195
|
+
throw new Error('ACP workspace-service credential is invalid');
|
|
196
|
+
await service.authorizeCaller(workspace.id, { kind: 'agent', ...identity });
|
|
197
|
+
sink.stream({
|
|
198
|
+
type: 'external_session_id',
|
|
199
|
+
externalSessionId: 'cleanup-retry-external',
|
|
200
|
+
});
|
|
201
|
+
const completion = deferred();
|
|
202
|
+
turns.push(completion);
|
|
203
|
+
generationTurns.push(completion);
|
|
204
|
+
return { completion: completion.promise };
|
|
205
|
+
}),
|
|
206
|
+
cancelTurn: vi.fn(async () => {
|
|
207
|
+
generationTurns.at(-1)?.resolve({ stopReason: 'cancelled' });
|
|
208
|
+
}),
|
|
209
|
+
close: vi.fn(async () => {
|
|
210
|
+
generationTurns.at(-1)?.resolve({ stopReason: 'cancelled' });
|
|
211
|
+
if (generation === 1) {
|
|
212
|
+
const failure = remainingCloseFailures.shift();
|
|
213
|
+
if (failure)
|
|
214
|
+
throw new Error(failure);
|
|
215
|
+
}
|
|
216
|
+
}),
|
|
217
|
+
};
|
|
218
|
+
await openingSink.process({
|
|
219
|
+
type: 'started',
|
|
220
|
+
runtimeInstanceId: driverSession.runtimeInstanceId,
|
|
221
|
+
launchClaimNumber: input.launchClaimNumber,
|
|
222
|
+
pid: 4400 + generation,
|
|
223
|
+
processGroupId: String(4400 + generation),
|
|
224
|
+
birthMarker: `test-birth:${4400 + generation}`,
|
|
225
|
+
ownershipToken: `test-owner:${4400 + generation}`,
|
|
226
|
+
});
|
|
227
|
+
driverSessions.push(driverSession);
|
|
228
|
+
return driverSession;
|
|
229
|
+
}),
|
|
230
|
+
};
|
|
231
|
+
const eventBus = new EventBus();
|
|
232
|
+
const manager = new SessionManager(eventBus, undefined, new StaticRuntimeRegistry([driver]));
|
|
233
|
+
const completed = waitForEvent(eventBus, 'session:completed');
|
|
234
|
+
await manager.start(session.id);
|
|
235
|
+
turns[0].resolve({ stopReason: 'end_turn' });
|
|
236
|
+
await completed;
|
|
237
|
+
await vi.waitFor(() => expect(sessionMsgStoreManager.has(session.id)).toBe(false));
|
|
238
|
+
await manager.sendMessage(session.id, 'use workspace service again');
|
|
239
|
+
return {
|
|
240
|
+
driver,
|
|
241
|
+
driverSessions,
|
|
242
|
+
manager,
|
|
243
|
+
observedCredentials,
|
|
244
|
+
session,
|
|
245
|
+
turns,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
async function getPersistedCleanupInput(sessionId, runtimeInstanceId) {
|
|
249
|
+
const process = await prisma.executionProcess.findFirstOrThrow({
|
|
250
|
+
where: { sessionId, runtimeInstanceId },
|
|
251
|
+
});
|
|
252
|
+
if (process.launchClaimNumber == null
|
|
253
|
+
|| process.pid == null
|
|
254
|
+
|| process.processGroupId == null
|
|
255
|
+
|| process.birthMarker == null
|
|
256
|
+
|| process.ownershipToken == null) {
|
|
257
|
+
throw new Error('Test runtime process identity is incomplete');
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
sessionId,
|
|
261
|
+
runtimeInstanceId,
|
|
262
|
+
launchClaimNumber: process.launchClaimNumber,
|
|
263
|
+
pid: process.pid,
|
|
264
|
+
processGroupId: process.processGroupId,
|
|
265
|
+
birthMarker: process.birthMarker,
|
|
266
|
+
ownershipToken: process.ownershipToken,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
154
269
|
describe('SessionManager session status vs real process state', () => {
|
|
155
270
|
beforeAll(async () => {
|
|
156
271
|
execFileSync('pnpm', ['exec', 'prisma', 'db', 'push', '--skip-generate', `--schema=${schemaPath}`], {
|
|
@@ -310,7 +425,7 @@ describe('SessionManager session status vs real process state', () => {
|
|
|
310
425
|
.resolves.toMatchObject({ status: SessionStatus.RUNNING });
|
|
311
426
|
await manager.destroyAll();
|
|
312
427
|
});
|
|
313
|
-
it('
|
|
428
|
+
it('serializes ACP stop and resend, then reconnects with the persisted external session id', async () => {
|
|
314
429
|
const provider = {
|
|
315
430
|
id: 'reused-acp-provider',
|
|
316
431
|
name: 'Reused ACP Provider',
|
|
@@ -356,11 +471,26 @@ describe('SessionManager session status vs real process state', () => {
|
|
|
356
471
|
.mockReturnValueOnce(false)
|
|
357
472
|
.mockReturnValueOnce(true)
|
|
358
473
|
.mockReturnValue(false);
|
|
474
|
+
const stopCleanup = deferred();
|
|
475
|
+
const withStartAdmission = async (towerSessionId, operation) => operation({
|
|
476
|
+
towerSessionId,
|
|
477
|
+
signal: new AbortController().signal,
|
|
478
|
+
throwIfCancelled: () => undefined,
|
|
479
|
+
});
|
|
359
480
|
const runtimeCoordinator = {
|
|
360
481
|
hasActiveTurn,
|
|
482
|
+
withStartAdmission: vi.fn(withStartAdmission),
|
|
483
|
+
retryDisposedSessionCleanup: vi.fn(async () => undefined),
|
|
361
484
|
startTurn: vi.fn(async (_input) => ({ turnId: 'turn-2', completion })),
|
|
362
485
|
abandonTurn: vi.fn(async () => true),
|
|
363
486
|
cancelTurn: vi.fn(async () => undefined),
|
|
487
|
+
cancelAndDisposeSession: vi.fn(async () => {
|
|
488
|
+
await stopCleanup.promise;
|
|
489
|
+
await prisma.executionProcess.updateMany({
|
|
490
|
+
where: { sessionId: session.id },
|
|
491
|
+
data: { cleanupState: 'CONFIRMED' },
|
|
492
|
+
});
|
|
493
|
+
}),
|
|
364
494
|
disposeSession: vi.fn(async () => {
|
|
365
495
|
await prisma.executionProcess.updateMany({
|
|
366
496
|
where: { sessionId: session.id },
|
|
@@ -398,12 +528,29 @@ describe('SessionManager session status vs real process state', () => {
|
|
|
398
528
|
where: { id: session.id },
|
|
399
529
|
data: { status: SessionStatus.COMPLETED },
|
|
400
530
|
});
|
|
401
|
-
|
|
402
|
-
|
|
531
|
+
const stopping = manager.stop(session.id);
|
|
532
|
+
await vi.waitFor(() => {
|
|
533
|
+
expect(runtimeCoordinator.cancelAndDisposeSession).toHaveBeenCalledWith(session.id);
|
|
534
|
+
});
|
|
535
|
+
const resending = manager.sendMessage(session.id, 'resend after explicit stop');
|
|
536
|
+
await Promise.resolve();
|
|
537
|
+
expect(runtimeCoordinator.startTurn).toHaveBeenCalledTimes(1);
|
|
538
|
+
stopCleanup.resolve();
|
|
539
|
+
await stopping;
|
|
540
|
+
await resending;
|
|
541
|
+
expect(runtimeCoordinator.abandonTurn).not.toHaveBeenCalled();
|
|
403
542
|
expect(runtimeCoordinator.cancelTurn).not.toHaveBeenCalled();
|
|
404
|
-
expect(runtimeCoordinator.disposeSession).
|
|
543
|
+
expect(runtimeCoordinator.disposeSession).not.toHaveBeenCalled();
|
|
544
|
+
expect(runtimeCoordinator.startTurn).toHaveBeenCalledTimes(2);
|
|
545
|
+
expect(runtimeCoordinator.startTurn.mock.calls[1]?.[0]).toEqual(expect.objectContaining({
|
|
546
|
+
towerSessionId: session.id,
|
|
547
|
+
externalSessionId: 'external-acp-session',
|
|
548
|
+
resumeExternalSessionId: 'external-acp-session',
|
|
549
|
+
resumeMode: 'resume',
|
|
550
|
+
prompt: expect.stringContaining('resend after explicit stop'),
|
|
551
|
+
}));
|
|
405
552
|
expect((await prisma.session.findUnique({ where: { id: session.id } }))?.status)
|
|
406
|
-
.toBe(SessionStatus.
|
|
553
|
+
.toBe(SessionStatus.RUNNING);
|
|
407
554
|
await manager.destroyAll();
|
|
408
555
|
});
|
|
409
556
|
it('loads ACP history for an incomplete session even when a snapshot exists', async () => {
|
|
@@ -432,8 +579,15 @@ describe('SessionManager session status vs real process state', () => {
|
|
|
432
579
|
},
|
|
433
580
|
});
|
|
434
581
|
const completion = new Promise(() => undefined);
|
|
582
|
+
const withStartAdmission = async (towerSessionId, operation) => operation({
|
|
583
|
+
towerSessionId,
|
|
584
|
+
signal: new AbortController().signal,
|
|
585
|
+
throwIfCancelled: () => undefined,
|
|
586
|
+
});
|
|
435
587
|
const runtimeCoordinator = {
|
|
436
588
|
hasActiveTurn: vi.fn(() => false),
|
|
589
|
+
withStartAdmission: vi.fn(withStartAdmission),
|
|
590
|
+
retryDisposedSessionCleanup: vi.fn(async () => undefined),
|
|
437
591
|
startTurn: vi.fn(async (_input) => ({ turnId: 'turn-2', completion })),
|
|
438
592
|
abandonTurn: vi.fn(async () => true),
|
|
439
593
|
cancelTurn: vi.fn(async () => undefined),
|
|
@@ -455,75 +609,431 @@ describe('SessionManager session status vs real process state', () => {
|
|
|
455
609
|
.toBe(startInput?.historyBoundaryEntryId);
|
|
456
610
|
await manager.destroyAll();
|
|
457
611
|
});
|
|
458
|
-
it('
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
612
|
+
it('retries a retained DISPOSED owner before an immediate ACP resend starts a new generation', async () => {
|
|
613
|
+
const { driver, driverSessions, manager, observedCredentials, session, } = await createRetryableAcpStopFixture(['owned tree still alive']);
|
|
614
|
+
const firstDriverSession = driverSessions[0];
|
|
615
|
+
expect(observedCredentials).toHaveLength(2);
|
|
616
|
+
expect(observedCredentials[1]).toBe(observedCredentials[0]);
|
|
617
|
+
await expect(manager.stop(session.id)).rejects.toThrow('owned tree still alive');
|
|
618
|
+
expect(firstDriverSession.close).toHaveBeenCalledOnce();
|
|
619
|
+
expect(validateAgentApiCredential(observedCredentials[1])).toBeNull();
|
|
620
|
+
await expect(manager.sendMessage(session.id, 'resend immediately after failed stop'))
|
|
621
|
+
.resolves.toMatchObject({ id: session.id });
|
|
622
|
+
expect(firstDriverSession.close).toHaveBeenCalledTimes(2);
|
|
623
|
+
expect(driver.open).toHaveBeenCalledTimes(2);
|
|
624
|
+
expect(vi.mocked(driver.open).mock.calls[1]?.[0]).toEqual(expect.objectContaining({
|
|
625
|
+
externalSessionId: 'cleanup-retry-external',
|
|
626
|
+
}));
|
|
627
|
+
expect(observedCredentials).toHaveLength(3);
|
|
628
|
+
expect(observedCredentials[2]).not.toBe(observedCredentials[1]);
|
|
629
|
+
expect(validateAgentApiCredential(observedCredentials[2])).toMatchObject({ sessionId: session.id });
|
|
630
|
+
expect(manager.hasRuntimeProcessOwner('cleanup-retry-runtime-1', session.id, 1)).toBe(false);
|
|
631
|
+
await expect(prisma.executionProcess.findFirstOrThrow({
|
|
632
|
+
where: { sessionId: session.id, runtimeInstanceId: 'cleanup-retry-runtime-1' },
|
|
633
|
+
})).resolves.toMatchObject({
|
|
634
|
+
launchClaimNumber: 1,
|
|
635
|
+
cleanupState: 'CONFIRMED',
|
|
636
|
+
cleanupError: null,
|
|
637
|
+
cleanupAttemptCount: 1,
|
|
638
|
+
});
|
|
639
|
+
await expect(prisma.session.findUnique({ where: { id: session.id } })).resolves.toMatchObject({
|
|
640
|
+
status: SessionStatus.RUNNING,
|
|
641
|
+
runtimeLaunchState: 'PROCESS_RECORDED',
|
|
642
|
+
runtimeLaunchClaimCount: 3,
|
|
643
|
+
runtimeLaunchResolvedCount: 3,
|
|
644
|
+
runtimeLaunchProcessCount: 2,
|
|
645
|
+
runtimeLaunchDiagnostic: null,
|
|
646
|
+
});
|
|
647
|
+
expect(await prisma.executionProcess.count({
|
|
648
|
+
where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
|
|
649
|
+
})).toBe(0);
|
|
650
|
+
await manager.destroyAll();
|
|
651
|
+
});
|
|
652
|
+
it('rejects an immediate ACP resend without advancing launch state when retained cleanup fails again', async () => {
|
|
653
|
+
const { driver, driverSessions, manager, observedCredentials, session, } = await createRetryableAcpStopFixture([
|
|
654
|
+
'owned tree still alive',
|
|
655
|
+
'owned tree still alive on resend',
|
|
656
|
+
]);
|
|
657
|
+
const firstDriverSession = driverSessions[0];
|
|
658
|
+
await expect(manager.stop(session.id)).rejects.toThrow('owned tree still alive');
|
|
659
|
+
const launchStateBeforeResend = await prisma.session.findUniqueOrThrow({
|
|
471
660
|
where: { id: session.id },
|
|
472
|
-
|
|
661
|
+
select: {
|
|
662
|
+
status: true,
|
|
663
|
+
runtimeLaunchState: true,
|
|
664
|
+
runtimeLaunchClaimCount: true,
|
|
665
|
+
runtimeLaunchResolvedCount: true,
|
|
666
|
+
runtimeLaunchProcessCount: true,
|
|
667
|
+
runtimeLaunchDiagnostic: true,
|
|
668
|
+
},
|
|
473
669
|
});
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
loadSession: true,
|
|
480
|
-
terminalInput: false,
|
|
481
|
-
terminalResize: false,
|
|
482
|
-
permissions: true,
|
|
483
|
-
};
|
|
484
|
-
const driverSession = {
|
|
485
|
-
runtimeInstanceId: 'credential-acp-runtime',
|
|
486
|
-
capabilities,
|
|
487
|
-
externalSessionId: 'credential-acp-external',
|
|
488
|
-
runTurn: vi.fn(async (_turn) => {
|
|
489
|
-
const credential = openInput?.env.get(AGENT_API_CREDENTIAL_ENV) ?? '';
|
|
490
|
-
observedCredentials.push(credential);
|
|
491
|
-
const identity = validateAgentApiCredential(credential);
|
|
492
|
-
if (!identity)
|
|
493
|
-
throw new Error('ACP workspace-service credential is invalid');
|
|
494
|
-
await service.authorizeCaller(workspace.id, { kind: 'agent', ...identity });
|
|
495
|
-
const completion = deferred();
|
|
496
|
-
turns.push(completion);
|
|
497
|
-
return { completion: completion.promise };
|
|
498
|
-
}),
|
|
499
|
-
cancelTurn: vi.fn(async () => {
|
|
500
|
-
turns.at(-1)?.resolve({ stopReason: 'cancelled' });
|
|
501
|
-
}),
|
|
502
|
-
close: vi.fn(async () => undefined),
|
|
503
|
-
};
|
|
504
|
-
const driver = {
|
|
505
|
-
type: RuntimeType.ACP,
|
|
506
|
-
open: vi.fn(async (input) => {
|
|
507
|
-
openInput = input;
|
|
508
|
-
return driverSession;
|
|
509
|
-
}),
|
|
510
|
-
};
|
|
511
|
-
const eventBus = new EventBus();
|
|
512
|
-
const manager = new SessionManager(eventBus, undefined, new StaticRuntimeRegistry([driver]));
|
|
513
|
-
const completed = waitForEvent(eventBus, 'session:completed');
|
|
514
|
-
await manager.start(session.id);
|
|
515
|
-
turns[0].resolve({ stopReason: 'end_turn' });
|
|
516
|
-
await completed;
|
|
517
|
-
await vi.waitFor(() => expect(sessionMsgStoreManager.has(session.id)).toBe(false));
|
|
518
|
-
await manager.sendMessage(session.id, 'use workspace service again');
|
|
670
|
+
expect(manager.terminalSessions.get(session.id)).toBe(SessionStatus.CANCELLED);
|
|
671
|
+
await expect(manager.sendMessage(session.id, 'resend while cleanup still fails'))
|
|
672
|
+
.rejects.toThrow('owned tree still alive on resend');
|
|
673
|
+
expect(firstDriverSession.close).toHaveBeenCalledTimes(2);
|
|
674
|
+
expect(driver.open).toHaveBeenCalledOnce();
|
|
519
675
|
expect(observedCredentials).toHaveLength(2);
|
|
520
|
-
expect(observedCredentials[1]).toBe(observedCredentials[0]);
|
|
521
|
-
expect(validateAgentApiCredential(observedCredentials[1])).toMatchObject({ sessionId: session.id });
|
|
522
|
-
await manager.stop(session.id);
|
|
523
|
-
expect(driverSession.close).toHaveBeenCalledOnce();
|
|
524
676
|
expect(validateAgentApiCredential(observedCredentials[1])).toBeNull();
|
|
677
|
+
expect(manager.terminalSessions.get(session.id)).toBe(SessionStatus.CANCELLED);
|
|
678
|
+
await expect(prisma.session.findUniqueOrThrow({
|
|
679
|
+
where: { id: session.id },
|
|
680
|
+
select: {
|
|
681
|
+
status: true,
|
|
682
|
+
runtimeLaunchState: true,
|
|
683
|
+
runtimeLaunchClaimCount: true,
|
|
684
|
+
runtimeLaunchResolvedCount: true,
|
|
685
|
+
runtimeLaunchProcessCount: true,
|
|
686
|
+
runtimeLaunchDiagnostic: true,
|
|
687
|
+
},
|
|
688
|
+
})).resolves.toEqual(launchStateBeforeResend);
|
|
689
|
+
expect(manager.hasRuntimeProcessOwner('cleanup-retry-runtime-1', session.id, 1)).toBe(true);
|
|
690
|
+
await expect(prisma.executionProcess.findFirstOrThrow({
|
|
691
|
+
where: { sessionId: session.id, runtimeInstanceId: 'cleanup-retry-runtime-1' },
|
|
692
|
+
})).resolves.toMatchObject({
|
|
693
|
+
launchClaimNumber: 1,
|
|
694
|
+
pid: 4401,
|
|
695
|
+
processGroupId: '4401',
|
|
696
|
+
birthMarker: 'test-birth:4401',
|
|
697
|
+
ownershipToken: 'test-owner:4401',
|
|
698
|
+
cleanupState: 'FAILED',
|
|
699
|
+
cleanupError: 'owned tree still alive on resend',
|
|
700
|
+
cleanupAttemptCount: 2,
|
|
701
|
+
});
|
|
702
|
+
expect(await prisma.executionProcess.count({
|
|
703
|
+
where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
|
|
704
|
+
})).toBe(0);
|
|
705
|
+
await expect(manager.stop(session.id)).resolves.toMatchObject({ id: session.id });
|
|
706
|
+
expect(firstDriverSession.close).toHaveBeenCalledTimes(3);
|
|
707
|
+
await expect(manager.sendMessage(session.id, 'send after cleanup recovery'))
|
|
708
|
+
.resolves.toMatchObject({ id: session.id });
|
|
709
|
+
expect(driver.open).toHaveBeenCalledTimes(2);
|
|
710
|
+
expect(observedCredentials).toHaveLength(3);
|
|
711
|
+
expect(validateAgentApiCredential(observedCredentials[2])).toMatchObject({ sessionId: session.id });
|
|
712
|
+
await expect(prisma.session.findUnique({ where: { id: session.id } })).resolves.toMatchObject({
|
|
713
|
+
status: SessionStatus.RUNNING,
|
|
714
|
+
runtimeLaunchState: 'PROCESS_RECORDED',
|
|
715
|
+
runtimeLaunchClaimCount: 3,
|
|
716
|
+
runtimeLaunchResolvedCount: 3,
|
|
717
|
+
runtimeLaunchProcessCount: 2,
|
|
718
|
+
runtimeLaunchDiagnostic: null,
|
|
719
|
+
});
|
|
720
|
+
expect(await prisma.executionProcess.count({
|
|
721
|
+
where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
|
|
722
|
+
})).toBe(0);
|
|
723
|
+
await manager.destroyAll();
|
|
724
|
+
});
|
|
725
|
+
it('holds background cleanup retry and resend behind the same disposal evidence boundary', async () => {
|
|
726
|
+
const { driver, driverSessions, manager, observedCredentials, session, } = await createRetryableAcpStopFixture([]);
|
|
727
|
+
const firstDriverSession = driverSessions[0];
|
|
728
|
+
const evidenceStarted = deferred();
|
|
729
|
+
const evidenceRelease = deferred();
|
|
730
|
+
const originalMarkCleanup = manager.markRuntimeProcessCleanupState.bind(manager);
|
|
731
|
+
const markCleanupSpy = vi.spyOn(manager, 'markRuntimeProcessCleanupState')
|
|
732
|
+
.mockImplementation(async (...args) => {
|
|
733
|
+
if (args[1] === 'cleanup-retry-runtime-1' && args[2] === 'CONFIRMED') {
|
|
734
|
+
evidenceStarted.resolve();
|
|
735
|
+
await evidenceRelease.promise;
|
|
736
|
+
}
|
|
737
|
+
return originalMarkCleanup(...args);
|
|
738
|
+
});
|
|
739
|
+
const coordinator = manager.runtimeCoordinator;
|
|
740
|
+
const retryDisposedSpy = vi.spyOn(coordinator, 'retryDisposedSessionCleanup');
|
|
741
|
+
const disposal = manager.disposeRuntimeSession(session.id, 'cleanup-retry-runtime-1');
|
|
742
|
+
await evidenceStarted.promise;
|
|
743
|
+
const cleanupInput = await getPersistedCleanupInput(session.id, 'cleanup-retry-runtime-1');
|
|
744
|
+
const launchStateBeforeAdmission = await prisma.session.findUniqueOrThrow({
|
|
745
|
+
where: { id: session.id },
|
|
746
|
+
select: {
|
|
747
|
+
status: true,
|
|
748
|
+
runtimeLaunchState: true,
|
|
749
|
+
runtimeLaunchClaimCount: true,
|
|
750
|
+
runtimeLaunchResolvedCount: true,
|
|
751
|
+
runtimeLaunchProcessCount: true,
|
|
752
|
+
runtimeLaunchDiagnostic: true,
|
|
753
|
+
},
|
|
754
|
+
});
|
|
755
|
+
const backgroundCleanup = manager.retryRuntimeProcessCleanup(cleanupInput);
|
|
756
|
+
const resend = manager.sendMessage(session.id, 'resend during cleanup evidence persistence');
|
|
757
|
+
await vi.waitFor(() => expect(retryDisposedSpy).toHaveBeenCalledTimes(2));
|
|
758
|
+
expect(firstDriverSession.close).toHaveBeenCalledOnce();
|
|
759
|
+
expect(driver.open).toHaveBeenCalledOnce();
|
|
760
|
+
expect(observedCredentials).toHaveLength(2);
|
|
761
|
+
await expect(prisma.session.findUniqueOrThrow({
|
|
762
|
+
where: { id: session.id },
|
|
763
|
+
select: {
|
|
764
|
+
status: true,
|
|
765
|
+
runtimeLaunchState: true,
|
|
766
|
+
runtimeLaunchClaimCount: true,
|
|
767
|
+
runtimeLaunchResolvedCount: true,
|
|
768
|
+
runtimeLaunchProcessCount: true,
|
|
769
|
+
runtimeLaunchDiagnostic: true,
|
|
770
|
+
},
|
|
771
|
+
})).resolves.toEqual(launchStateBeforeAdmission);
|
|
772
|
+
expect(await prisma.executionProcess.count({ where: { sessionId: session.id } })).toBe(1);
|
|
773
|
+
expect(await prisma.executionProcess.count({
|
|
774
|
+
where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
|
|
775
|
+
})).toBe(0);
|
|
776
|
+
evidenceRelease.resolve();
|
|
777
|
+
await Promise.all([disposal, backgroundCleanup, resend]);
|
|
778
|
+
expect(firstDriverSession.close).toHaveBeenCalledOnce();
|
|
779
|
+
expect(driver.open).toHaveBeenCalledTimes(2);
|
|
780
|
+
expect(observedCredentials).toHaveLength(3);
|
|
781
|
+
await expect(prisma.executionProcess.findFirstOrThrow({
|
|
782
|
+
where: { sessionId: session.id, runtimeInstanceId: 'cleanup-retry-runtime-1' },
|
|
783
|
+
})).resolves.toMatchObject({ cleanupState: 'CONFIRMED' });
|
|
784
|
+
expect(await prisma.executionProcess.count({
|
|
785
|
+
where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
|
|
786
|
+
})).toBe(0);
|
|
787
|
+
retryDisposedSpy.mockRestore();
|
|
788
|
+
markCleanupSpy.mockRestore();
|
|
789
|
+
await manager.destroyAll();
|
|
790
|
+
});
|
|
791
|
+
it('rejects concurrent cleanup and resend without a phantom claim when disposal evidence remains unconfirmed', async () => {
|
|
792
|
+
const { driver, driverSessions, manager, observedCredentials, session, } = await createRetryableAcpStopFixture([]);
|
|
793
|
+
const firstDriverSession = driverSessions[0];
|
|
794
|
+
const evidenceStarted = deferred();
|
|
795
|
+
const evidenceRelease = deferred();
|
|
796
|
+
let skipEvidencePersistence = true;
|
|
797
|
+
const originalMarkCleanup = manager.markRuntimeProcessCleanupState.bind(manager);
|
|
798
|
+
const markCleanupSpy = vi.spyOn(manager, 'markRuntimeProcessCleanupState')
|
|
799
|
+
.mockImplementation(async (...args) => {
|
|
800
|
+
if (args[1] === 'cleanup-retry-runtime-1'
|
|
801
|
+
&& args[2] === 'CONFIRMED'
|
|
802
|
+
&& skipEvidencePersistence) {
|
|
803
|
+
evidenceStarted.resolve();
|
|
804
|
+
await evidenceRelease.promise;
|
|
805
|
+
skipEvidencePersistence = false;
|
|
806
|
+
return undefined;
|
|
807
|
+
}
|
|
808
|
+
return originalMarkCleanup(...args);
|
|
809
|
+
});
|
|
810
|
+
const coordinator = manager.runtimeCoordinator;
|
|
811
|
+
const retryDisposedSpy = vi.spyOn(coordinator, 'retryDisposedSessionCleanup');
|
|
812
|
+
const disposal = manager.disposeRuntimeSession(session.id, 'cleanup-retry-runtime-1');
|
|
813
|
+
await evidenceStarted.promise;
|
|
814
|
+
const cleanupInput = await getPersistedCleanupInput(session.id, 'cleanup-retry-runtime-1');
|
|
815
|
+
const launchStateBeforeAdmission = await prisma.session.findUniqueOrThrow({
|
|
816
|
+
where: { id: session.id },
|
|
817
|
+
select: {
|
|
818
|
+
status: true,
|
|
819
|
+
runtimeLaunchState: true,
|
|
820
|
+
runtimeLaunchClaimCount: true,
|
|
821
|
+
runtimeLaunchResolvedCount: true,
|
|
822
|
+
runtimeLaunchProcessCount: true,
|
|
823
|
+
runtimeLaunchDiagnostic: true,
|
|
824
|
+
},
|
|
825
|
+
});
|
|
826
|
+
const backgroundCleanup = manager.retryRuntimeProcessCleanup(cleanupInput);
|
|
827
|
+
const resend = manager.sendMessage(session.id, 'resend while evidence persistence fails');
|
|
828
|
+
await vi.waitFor(() => expect(retryDisposedSpy).toHaveBeenCalledTimes(2));
|
|
829
|
+
const disposalResult = expect(disposal).rejects.toThrow('is not durably confirmed');
|
|
830
|
+
const backgroundResult = expect(backgroundCleanup).rejects.toMatchObject({
|
|
831
|
+
code: 'runtime_cleanup_pending',
|
|
832
|
+
retryable: true,
|
|
833
|
+
});
|
|
834
|
+
const resendResult = expect(resend).rejects.toMatchObject({
|
|
835
|
+
code: 'runtime_cleanup_pending',
|
|
836
|
+
retryable: true,
|
|
837
|
+
});
|
|
838
|
+
evidenceRelease.resolve();
|
|
839
|
+
await Promise.all([disposalResult, backgroundResult, resendResult]);
|
|
840
|
+
expect(firstDriverSession.close).toHaveBeenCalledOnce();
|
|
841
|
+
expect(driver.open).toHaveBeenCalledOnce();
|
|
842
|
+
expect(observedCredentials).toHaveLength(2);
|
|
843
|
+
await expect(prisma.session.findUniqueOrThrow({
|
|
844
|
+
where: { id: session.id },
|
|
845
|
+
select: {
|
|
846
|
+
status: true,
|
|
847
|
+
runtimeLaunchState: true,
|
|
848
|
+
runtimeLaunchClaimCount: true,
|
|
849
|
+
runtimeLaunchResolvedCount: true,
|
|
850
|
+
runtimeLaunchProcessCount: true,
|
|
851
|
+
runtimeLaunchDiagnostic: true,
|
|
852
|
+
},
|
|
853
|
+
})).resolves.toEqual(launchStateBeforeAdmission);
|
|
854
|
+
expect(manager.hasRuntimeProcessOwner('cleanup-retry-runtime-1', session.id, 1)).toBe(true);
|
|
855
|
+
await expect(prisma.executionProcess.findFirstOrThrow({
|
|
856
|
+
where: { sessionId: session.id, runtimeInstanceId: 'cleanup-retry-runtime-1' },
|
|
857
|
+
})).resolves.toMatchObject({ cleanupState: 'FAILED' });
|
|
858
|
+
expect(await prisma.executionProcess.count({
|
|
859
|
+
where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
|
|
860
|
+
})).toBe(0);
|
|
861
|
+
await expect(manager.retryRuntimeProcessCleanup(cleanupInput)).resolves.toBeUndefined();
|
|
862
|
+
expect(firstDriverSession.close).toHaveBeenCalledOnce();
|
|
863
|
+
expect(manager.hasRuntimeProcessOwner('cleanup-retry-runtime-1', session.id, 1)).toBe(false);
|
|
864
|
+
await expect(manager.sendMessage(session.id, 'resend after evidence recovery'))
|
|
865
|
+
.resolves.toMatchObject({ id: session.id });
|
|
866
|
+
expect(driver.open).toHaveBeenCalledTimes(2);
|
|
867
|
+
expect(observedCredentials).toHaveLength(3);
|
|
868
|
+
expect(await prisma.executionProcess.count({
|
|
869
|
+
where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
|
|
870
|
+
})).toBe(0);
|
|
871
|
+
retryDisposedSpy.mockRestore();
|
|
872
|
+
markCleanupSpy.mockRestore();
|
|
873
|
+
await manager.destroyAll();
|
|
874
|
+
});
|
|
875
|
+
it('cancels an admission-first resend before background cleanup can close its DriverSession', async () => {
|
|
876
|
+
const { driver, driverSessions, manager, observedCredentials, session, turns, } = await createRetryableAcpStopFixture([]);
|
|
877
|
+
const driverSession = driverSessions[0];
|
|
878
|
+
turns.at(-1).resolve({ stopReason: 'end_turn' });
|
|
879
|
+
await vi.waitFor(async () => {
|
|
880
|
+
const current = await prisma.session.findUniqueOrThrow({ where: { id: session.id } });
|
|
881
|
+
expect(current.status).toBe(SessionStatus.COMPLETED);
|
|
882
|
+
});
|
|
883
|
+
const runTurnEntered = deferred();
|
|
884
|
+
const runTurnRelease = deferred();
|
|
885
|
+
vi.mocked(driverSession.runTurn).mockImplementationOnce(async (turn) => {
|
|
886
|
+
runTurnEntered.resolve();
|
|
887
|
+
await runTurnRelease.promise;
|
|
888
|
+
turn.admissionSignal?.throwIfAborted();
|
|
889
|
+
return { completion: new Promise(() => undefined) };
|
|
890
|
+
});
|
|
891
|
+
const cleanupInput = await getPersistedCleanupInput(session.id, 'cleanup-retry-runtime-1');
|
|
892
|
+
const resend = manager.sendMessage(session.id, 'resend interrupted during driver handoff');
|
|
893
|
+
await runTurnEntered.promise;
|
|
894
|
+
const backgroundCleanup = manager.retryRuntimeProcessCleanup(cleanupInput);
|
|
895
|
+
await Promise.resolve();
|
|
896
|
+
expect(driverSession.close).not.toHaveBeenCalled();
|
|
897
|
+
runTurnRelease.resolve();
|
|
898
|
+
await expect(resend).rejects.toMatchObject({ code: 'runtime_admission_cancelled', retryable: true });
|
|
899
|
+
await expect(backgroundCleanup).resolves.toBeUndefined();
|
|
900
|
+
expect(driver.open).toHaveBeenCalledOnce();
|
|
901
|
+
expect(driverSession.close).toHaveBeenCalledOnce();
|
|
902
|
+
expect(new Set(observedCredentials).size).toBe(1);
|
|
903
|
+
expect(validateAgentApiCredential(observedCredentials[0])).toBeNull();
|
|
904
|
+
await expect(prisma.session.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({
|
|
905
|
+
runtimeLaunchState: 'SAFE_PRE_CHILD_FAILURE',
|
|
906
|
+
runtimeLaunchClaimCount: 3,
|
|
907
|
+
runtimeLaunchResolvedCount: 3,
|
|
908
|
+
runtimeLaunchProcessCount: 1,
|
|
909
|
+
});
|
|
910
|
+
await expect(prisma.executionProcess.findFirstOrThrow({
|
|
911
|
+
where: { sessionId: session.id, runtimeInstanceId: 'cleanup-retry-runtime-1' },
|
|
912
|
+
})).resolves.toMatchObject({ cleanupState: 'CONFIRMED' });
|
|
913
|
+
expect(await prisma.executionProcess.count({
|
|
914
|
+
where: { sessionId: session.id, cleanupState: 'QUARANTINED' },
|
|
915
|
+
})).toBe(0);
|
|
525
916
|
await manager.destroyAll();
|
|
526
917
|
});
|
|
918
|
+
it.each(['start', 'startFollowUp'])('revokes a public %s before runtime admission when stop wins during preparation', async (method) => {
|
|
919
|
+
const { workspace, session } = await createSessionFixture();
|
|
920
|
+
const resumeSource = await prisma.session.create({
|
|
921
|
+
data: {
|
|
922
|
+
workspaceId: workspace.id,
|
|
923
|
+
agentType: AgentType.CODEX,
|
|
924
|
+
prompt: 'previous turn',
|
|
925
|
+
status: SessionStatus.COMPLETED,
|
|
926
|
+
externalSessionId: 'previous-external-session',
|
|
927
|
+
},
|
|
928
|
+
});
|
|
929
|
+
const manager = new SessionManager(new EventBus());
|
|
930
|
+
const coordinator = manager.runtimeCoordinator;
|
|
931
|
+
const withStartAdmission = vi.spyOn(coordinator, 'withStartAdmission');
|
|
932
|
+
const sessionRead = deferred();
|
|
933
|
+
const originalFindSession = manager.findSessionExecutionRecord.bind(manager);
|
|
934
|
+
vi.spyOn(manager, 'findSessionExecutionRecord').mockImplementation(async (sessionId) => {
|
|
935
|
+
const record = await originalFindSession(String(sessionId));
|
|
936
|
+
sessionRead.resolve();
|
|
937
|
+
return record;
|
|
938
|
+
});
|
|
939
|
+
const preparationGate = deferred();
|
|
940
|
+
manager.pendingAutoCommits.set(session.id, preparationGate.promise);
|
|
941
|
+
const starting = method === 'start'
|
|
942
|
+
? manager.start(session.id)
|
|
943
|
+
: manager.startFollowUp(session.id, resumeSource.id);
|
|
944
|
+
const startingResult = expect(starting).rejects.toMatchObject({ code: 'SESSION_NOT_ADMITTED' });
|
|
945
|
+
await sessionRead.promise;
|
|
946
|
+
expect(withStartAdmission).not.toHaveBeenCalled();
|
|
947
|
+
await expect(manager.stop(session.id)).resolves.toMatchObject({ id: session.id });
|
|
948
|
+
await startingResult;
|
|
949
|
+
expect(withStartAdmission).not.toHaveBeenCalled();
|
|
950
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
951
|
+
await expect(prisma.session.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({
|
|
952
|
+
status: SessionStatus.CANCELLED,
|
|
953
|
+
runtimeLaunchState: 'NOT_STARTED',
|
|
954
|
+
runtimeLaunchClaimCount: 0,
|
|
955
|
+
});
|
|
956
|
+
preparationGate.resolve();
|
|
957
|
+
expect(withStartAdmission).not.toHaveBeenCalled();
|
|
958
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
959
|
+
const recoveryPty = new ControlledPty();
|
|
960
|
+
spawnMock.mockResolvedValueOnce(spawnResultFor(recoveryPty));
|
|
961
|
+
await expect(manager.sendMessage(session.id, 'recover after the completed stop'))
|
|
962
|
+
.resolves.toMatchObject({ id: session.id });
|
|
963
|
+
expect(spawnMock).toHaveBeenCalledOnce();
|
|
964
|
+
await manager.stop(session.id);
|
|
965
|
+
await manager.destroyAll();
|
|
966
|
+
});
|
|
967
|
+
it.each(['start', 'startFollowUp'])('does not start public %s preparation while stop intent is already in flight', async (method) => {
|
|
968
|
+
const { workspace, session } = await createSessionFixture();
|
|
969
|
+
const resumeSource = await prisma.session.create({
|
|
970
|
+
data: {
|
|
971
|
+
workspaceId: workspace.id,
|
|
972
|
+
agentType: AgentType.CODEX,
|
|
973
|
+
prompt: 'previous turn',
|
|
974
|
+
status: SessionStatus.COMPLETED,
|
|
975
|
+
},
|
|
976
|
+
});
|
|
977
|
+
const manager = new SessionManager(new EventBus());
|
|
978
|
+
const findSession = vi.spyOn(manager, 'findSessionExecutionRecord');
|
|
979
|
+
const waitForAutoCommit = vi.spyOn(manager, 'waitForPendingAutoCommit');
|
|
980
|
+
const stopBlocker = manager.reserveSessionAction(session.id);
|
|
981
|
+
const stopping = manager.stop(session.id);
|
|
982
|
+
try {
|
|
983
|
+
const starting = method === 'start'
|
|
984
|
+
? manager.start(session.id)
|
|
985
|
+
: manager.startFollowUp(session.id, resumeSource.id);
|
|
986
|
+
await expect(starting).rejects.toMatchObject({ code: 'SESSION_NOT_ADMITTED' });
|
|
987
|
+
expect(findSession).not.toHaveBeenCalled();
|
|
988
|
+
expect(waitForAutoCommit).not.toHaveBeenCalled();
|
|
989
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
990
|
+
}
|
|
991
|
+
finally {
|
|
992
|
+
stopBlocker.release();
|
|
993
|
+
await stopping;
|
|
994
|
+
await manager.destroyAll();
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
it.each(['start', 'startFollowUp'])('observes a late public %s preparation rejection after stop', async (method) => {
|
|
998
|
+
const { workspace, session } = await createSessionFixture();
|
|
999
|
+
const resumeSource = await prisma.session.create({
|
|
1000
|
+
data: {
|
|
1001
|
+
workspaceId: workspace.id,
|
|
1002
|
+
agentType: AgentType.CODEX,
|
|
1003
|
+
prompt: 'previous turn',
|
|
1004
|
+
status: SessionStatus.COMPLETED,
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1007
|
+
const manager = new SessionManager(new EventBus());
|
|
1008
|
+
const queryStarted = deferred();
|
|
1009
|
+
const query = deferred();
|
|
1010
|
+
vi.spyOn(manager, 'findSessionExecutionRecord').mockImplementation(() => {
|
|
1011
|
+
queryStarted.resolve();
|
|
1012
|
+
return query.promise;
|
|
1013
|
+
});
|
|
1014
|
+
const unhandledRejections = [];
|
|
1015
|
+
const recordUnhandledRejection = (reason) => {
|
|
1016
|
+
unhandledRejections.push(reason);
|
|
1017
|
+
};
|
|
1018
|
+
process.on('unhandledRejection', recordUnhandledRejection);
|
|
1019
|
+
try {
|
|
1020
|
+
const starting = method === 'start'
|
|
1021
|
+
? manager.start(session.id)
|
|
1022
|
+
: manager.startFollowUp(session.id, resumeSource.id);
|
|
1023
|
+
const startingResult = expect(starting).rejects.toMatchObject({ code: 'SESSION_NOT_ADMITTED' });
|
|
1024
|
+
await queryStarted.promise;
|
|
1025
|
+
await expect(manager.stop(session.id)).resolves.toMatchObject({ id: session.id });
|
|
1026
|
+
await startingResult;
|
|
1027
|
+
query.reject(new Error('late session lookup failure'));
|
|
1028
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1029
|
+
expect(unhandledRejections).toEqual([]);
|
|
1030
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
1031
|
+
}
|
|
1032
|
+
finally {
|
|
1033
|
+
process.off('unhandledRejection', recordUnhandledRejection);
|
|
1034
|
+
await manager.destroyAll();
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
527
1037
|
afterAll(async () => {
|
|
528
1038
|
await prisma.$disconnect();
|
|
529
1039
|
fs.rmSync(testDir, { recursive: true, force: true });
|