agent-tower 0.4.15 → 0.4.16-beta.3
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.test.js +2 -0
- package/dist/app.test.js.map +1 -1
- package/dist/core/event-bus.d.ts +2 -0
- package/dist/core/event-bus.d.ts.map +1 -1
- package/dist/core/event-bus.js.map +1 -1
- package/dist/executors/__tests__/codex.executor.test.d.ts +2 -0
- package/dist/executors/__tests__/codex.executor.test.d.ts.map +1 -0
- package/dist/executors/__tests__/codex.executor.test.js +28 -0
- package/dist/executors/__tests__/codex.executor.test.js.map +1 -0
- package/dist/executors/codex.executor.d.ts +1 -0
- package/dist/executors/codex.executor.d.ts.map +1 -1
- package/dist/executors/codex.executor.js +19 -1
- package/dist/executors/codex.executor.js.map +1 -1
- package/dist/git/git-cli.d.ts +18 -1
- package/dist/git/git-cli.d.ts.map +1 -1
- package/dist/git/git-cli.js +17 -1
- package/dist/git/git-cli.js.map +1 -1
- package/dist/git/worktree.manager.d.ts +29 -2
- package/dist/git/worktree.manager.d.ts.map +1 -1
- package/dist/git/worktree.manager.js +137 -16
- package/dist/git/worktree.manager.js.map +1 -1
- package/dist/git/worktree.manager.test.d.ts +2 -0
- package/dist/git/worktree.manager.test.d.ts.map +1 -0
- package/dist/git/worktree.manager.test.js +104 -0
- package/dist/git/worktree.manager.test.js.map +1 -0
- package/dist/mcp/context.d.ts +3 -0
- package/dist/mcp/context.d.ts.map +1 -1
- package/dist/mcp/context.js +10 -1
- package/dist/mcp/context.js.map +1 -1
- package/dist/mcp/http-client.d.ts +24 -1
- package/dist/mcp/http-client.d.ts.map +1 -1
- package/dist/mcp/http-client.js +37 -3
- package/dist/mcp/http-client.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +190 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/middleware/tunnel-auth.d.ts.map +1 -1
- package/dist/middleware/tunnel-auth.js +2 -0
- package/dist/middleware/tunnel-auth.js.map +1 -1
- package/dist/output/__tests__/codex-parser.test.d.ts +2 -0
- package/dist/output/__tests__/codex-parser.test.d.ts.map +1 -0
- package/dist/output/__tests__/codex-parser.test.js +148 -0
- package/dist/output/__tests__/codex-parser.test.js.map +1 -0
- package/dist/output/codex-parser.d.ts +12 -0
- package/dist/output/codex-parser.d.ts.map +1 -1
- package/dist/output/codex-parser.js +129 -12
- package/dist/output/codex-parser.js.map +1 -1
- package/dist/routes/__tests__/attachments.test.d.ts +2 -0
- package/dist/routes/__tests__/attachments.test.d.ts.map +1 -0
- package/dist/routes/__tests__/attachments.test.js +86 -0
- package/dist/routes/__tests__/attachments.test.js.map +1 -0
- package/dist/routes/__tests__/filesystem.test.d.ts +2 -0
- package/dist/routes/__tests__/filesystem.test.d.ts.map +1 -0
- package/dist/routes/__tests__/filesystem.test.js +80 -0
- package/dist/routes/__tests__/filesystem.test.js.map +1 -0
- package/dist/routes/__tests__/previews.test.d.ts +2 -0
- package/dist/routes/__tests__/previews.test.d.ts.map +1 -0
- package/dist/routes/__tests__/previews.test.js +89 -0
- package/dist/routes/__tests__/previews.test.js.map +1 -0
- package/dist/routes/__tests__/tasks.test.d.ts +2 -0
- package/dist/routes/__tests__/tasks.test.d.ts.map +1 -0
- package/dist/routes/__tests__/tasks.test.js +72 -0
- package/dist/routes/__tests__/tasks.test.js.map +1 -0
- package/dist/routes/attachments.d.ts.map +1 -1
- package/dist/routes/attachments.js +36 -16
- package/dist/routes/attachments.js.map +1 -1
- package/dist/routes/filesystem.d.ts.map +1 -1
- package/dist/routes/filesystem.js +24 -3
- package/dist/routes/filesystem.js.map +1 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +6 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/previews.d.ts +6 -0
- package/dist/routes/previews.d.ts.map +1 -0
- package/dist/routes/previews.js +413 -0
- package/dist/routes/previews.js.map +1 -0
- package/dist/routes/projects.d.ts.map +1 -1
- package/dist/routes/projects.js +1 -0
- package/dist/routes/projects.js.map +1 -1
- package/dist/routes/system.d.ts.map +1 -1
- package/dist/routes/system.js +35 -1
- package/dist/routes/system.js.map +1 -1
- package/dist/routes/tasks.js +2 -2
- package/dist/routes/tasks.js.map +1 -1
- package/dist/routes/team-runs.d.ts +11 -0
- package/dist/routes/team-runs.d.ts.map +1 -0
- package/dist/routes/team-runs.js +309 -0
- package/dist/routes/team-runs.js.map +1 -0
- package/dist/routes/tunnel.d.ts.map +1 -1
- package/dist/routes/tunnel.js +20 -0
- package/dist/routes/tunnel.js.map +1 -1
- package/dist/routes/workspaces.d.ts.map +1 -1
- package/dist/routes/workspaces.js +15 -1
- package/dist/routes/workspaces.js.map +1 -1
- package/dist/services/__tests__/preview.service.test.d.ts +2 -0
- package/dist/services/__tests__/preview.service.test.d.ts.map +1 -0
- package/dist/services/__tests__/preview.service.test.js +29 -0
- package/dist/services/__tests__/preview.service.test.js.map +1 -0
- package/dist/services/__tests__/session-manager.team-run.test.d.ts +2 -0
- package/dist/services/__tests__/session-manager.team-run.test.d.ts.map +1 -0
- package/dist/services/__tests__/session-manager.team-run.test.js +286 -0
- package/dist/services/__tests__/session-manager.team-run.test.js.map +1 -0
- package/dist/services/__tests__/task.service.test.d.ts +2 -0
- package/dist/services/__tests__/task.service.test.d.ts.map +1 -0
- package/dist/services/__tests__/task.service.test.js +65 -0
- package/dist/services/__tests__/task.service.test.js.map +1 -0
- package/dist/services/__tests__/team-lock.service.test.d.ts +2 -0
- package/dist/services/__tests__/team-lock.service.test.d.ts.map +1 -0
- package/dist/services/__tests__/team-lock.service.test.js +81 -0
- package/dist/services/__tests__/team-lock.service.test.js.map +1 -0
- package/dist/services/__tests__/team-reconciler.service.test.d.ts +2 -0
- package/dist/services/__tests__/team-reconciler.service.test.d.ts.map +1 -0
- package/dist/services/__tests__/team-reconciler.service.test.js +1536 -0
- package/dist/services/__tests__/team-reconciler.service.test.js.map +1 -0
- package/dist/services/__tests__/team-run.service.test.d.ts +2 -0
- package/dist/services/__tests__/team-run.service.test.d.ts.map +1 -0
- package/dist/services/__tests__/team-run.service.test.js +699 -0
- package/dist/services/__tests__/team-run.service.test.js.map +1 -0
- package/dist/services/__tests__/team-scheduler.service.test.d.ts +2 -0
- package/dist/services/__tests__/team-scheduler.service.test.d.ts.map +1 -0
- package/dist/services/__tests__/team-scheduler.service.test.js +1688 -0
- package/dist/services/__tests__/team-scheduler.service.test.js.map +1 -0
- package/dist/services/__tests__/tunnel.service.test.d.ts +2 -0
- package/dist/services/__tests__/tunnel.service.test.d.ts.map +1 -0
- package/dist/services/__tests__/tunnel.service.test.js +138 -0
- package/dist/services/__tests__/tunnel.service.test.js.map +1 -0
- package/dist/services/__tests__/workspace.service.test.d.ts +2 -0
- package/dist/services/__tests__/workspace.service.test.d.ts.map +1 -0
- package/dist/services/__tests__/workspace.service.test.js +695 -0
- package/dist/services/__tests__/workspace.service.test.js.map +1 -0
- package/dist/services/attachment-context.d.ts +3 -0
- package/dist/services/attachment-context.d.ts.map +1 -0
- package/dist/services/attachment-context.js +34 -0
- package/dist/services/attachment-context.js.map +1 -0
- package/dist/services/preview.service.d.ts +19 -0
- package/dist/services/preview.service.d.ts.map +1 -0
- package/dist/services/preview.service.js +147 -0
- package/dist/services/preview.service.js.map +1 -0
- package/dist/services/project.service.d.ts +2 -0
- package/dist/services/project.service.d.ts.map +1 -1
- package/dist/services/project.service.js +87 -18
- package/dist/services/project.service.js.map +1 -1
- package/dist/services/session-manager.d.ts +43 -1
- package/dist/services/session-manager.d.ts.map +1 -1
- package/dist/services/session-manager.js +110 -2
- package/dist/services/session-manager.js.map +1 -1
- package/dist/services/task.service.d.ts +6 -0
- package/dist/services/task.service.d.ts.map +1 -1
- package/dist/services/task.service.js +15 -3
- package/dist/services/task.service.js.map +1 -1
- package/dist/services/team-lock.service.d.ts +25 -0
- package/dist/services/team-lock.service.d.ts.map +1 -0
- package/dist/services/team-lock.service.js +56 -0
- package/dist/services/team-lock.service.js.map +1 -0
- package/dist/services/team-reconciler.service.d.ts +44 -0
- package/dist/services/team-reconciler.service.d.ts.map +1 -0
- package/dist/services/team-reconciler.service.js +286 -0
- package/dist/services/team-reconciler.service.js.map +1 -0
- package/dist/services/team-run-events.d.ts +13 -0
- package/dist/services/team-run-events.d.ts.map +1 -0
- package/dist/services/team-run-events.js +27 -0
- package/dist/services/team-run-events.js.map +1 -0
- package/dist/services/team-run.service.d.ts +92 -0
- package/dist/services/team-run.service.d.ts.map +1 -0
- package/dist/services/team-run.service.js +835 -0
- package/dist/services/team-run.service.js.map +1 -0
- package/dist/services/team-scheduler.service.d.ts +104 -0
- package/dist/services/team-scheduler.service.d.ts.map +1 -0
- package/dist/services/team-scheduler.service.js +843 -0
- package/dist/services/team-scheduler.service.js.map +1 -0
- package/dist/services/tunnel.service.d.ts +31 -5
- package/dist/services/tunnel.service.d.ts.map +1 -1
- package/dist/services/tunnel.service.js +293 -32
- package/dist/services/tunnel.service.js.map +1 -1
- package/dist/services/workspace.service.d.ts +161 -7
- package/dist/services/workspace.service.d.ts.map +1 -1
- package/dist/services/workspace.service.js +396 -51
- package/dist/services/workspace.service.js.map +1 -1
- package/dist/socket/events.d.ts +1 -1
- package/dist/socket/events.d.ts.map +1 -1
- package/dist/socket/events.js.map +1 -1
- package/dist/socket/socket-gateway.d.ts.map +1 -1
- package/dist/socket/socket-gateway.js +5 -1
- package/dist/socket/socket-gateway.js.map +1 -1
- package/dist/web/assets/AgentDemoPage-Bf6labVB.js +1 -0
- package/dist/web/assets/{DemoPage-XwuS8vNB.js → DemoPage-DlfG47rV.js} +3 -3
- package/dist/web/assets/{GeneralSettingsPage-CliIgpwf.js → GeneralSettingsPage-DefqwzVn.js} +1 -1
- package/dist/web/assets/MemberAvatar-DVw_TedB.js +1 -0
- package/dist/web/assets/NotificationSettingsPage-C9h1U1Za.js +1 -0
- package/dist/web/assets/{ProfileSettingsPage-CkU_kZKG.js → ProfileSettingsPage-BkZE2yVP.js} +1 -1
- package/dist/web/assets/ProjectKanbanPage-B1Ckl1uY.js +89 -0
- package/dist/web/assets/ProjectSettingsPage-ByZ13awb.js +2 -0
- package/dist/web/assets/{ProviderSettingsPage-CfvdeoEU.js → ProviderSettingsPage-DSQYe8B6.js} +12 -12
- package/dist/web/assets/TeamSettingsPage-DUukJ_Ih.js +1 -0
- package/dist/web/assets/agent-tower-logo-COx9gy77.png +0 -0
- package/dist/web/assets/{button-BWFTEdOr.js → button-Bpm98eOV.js} +1 -1
- package/dist/web/assets/{chevron-down-CuPdBAx-.js → chevron-down-DSKKXCi8.js} +1 -1
- package/dist/web/assets/{chevron-right-Cs8vYTMn.js → chevron-right-CZdDV9GU.js} +1 -1
- package/dist/web/assets/chevron-up-gnnlwvYe.js +1 -0
- package/dist/web/assets/{circle-check-BXZTzqw0.js → circle-check-DeD_VuLK.js} +1 -1
- package/dist/web/assets/{code-block-OCS4YCEC-BxUpvXK_.js → code-block-OCS4YCEC-BrGjkdjS.js} +1 -1
- package/dist/web/assets/{confirm-dialog-CDLHRthd.js → confirm-dialog-CEVVvAcE.js} +1 -1
- package/dist/web/assets/folder-picker-ZBQlFEWL.js +1 -0
- package/dist/web/assets/index-B5g4V0NU.js +13 -0
- package/dist/web/assets/index-ltjI8o6A.css +1 -0
- package/dist/web/assets/loader-circle-GMfBClX0.js +1 -0
- package/dist/web/assets/{log-adapter-CeKrvZcz.js → log-adapter-DKKM3sxS.js} +1 -1
- package/dist/web/assets/{mermaid-NOHMQCX5-BOSwJqP0.js → mermaid-NOHMQCX5-D5USvUiZ.js} +44 -44
- package/dist/web/assets/modal-JMpuh-LG.js +1 -0
- package/dist/web/assets/{pencil-BMxBxIhw.js → pencil-QrCW47nn.js} +1 -1
- package/dist/web/assets/{select-BUmRG0LY.js → select-CINRzLiE.js} +1 -1
- package/dist/web/assets/upload-vFxZxKHo.js +1 -0
- package/dist/web/assets/{use-profiles-C1vlPE-2.js → use-profiles-SrVWPYv0.js} +1 -1
- package/dist/web/assets/{use-providers-Cdxr4Jbz.js → use-providers-BihMydl0.js} +1 -1
- package/dist/web/avatars/presets/avatar-preset-01-developer.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-02-architect.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-03-tester.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-04-devops.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-05-data-scientist.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-06-frontend.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-07-backend.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-08-security.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-09-project-manager.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-10-product-manager.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-11-scrum-master.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-12-tech-lead.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-13-coordinator.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-14-mentor.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-15-reviewer.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-16-ui-designer.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-17-ux-researcher.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-18-documenter.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-19-translator.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-20-analyst.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-21-consultant.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-22-creative-director.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-23-support.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-24-assistant.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-25-robot.png +0 -0
- package/dist/web/avatars/presets/avatar-preset-grid.png +0 -0
- package/dist/web/index.html +2 -2
- package/node_modules/@agent-tower/shared/dist/socket/events.d.ts +10 -0
- package/node_modules/@agent-tower/shared/dist/socket/events.d.ts.map +1 -1
- package/node_modules/@agent-tower/shared/dist/socket/events.js +1 -0
- package/node_modules/@agent-tower/shared/dist/socket/events.js.map +1 -1
- package/node_modules/@agent-tower/shared/dist/types.d.ts +161 -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/node_modules/@prisma/client/.prisma/client/default.d.ts +1 -0
- package/node_modules/@prisma/client/.prisma/client/default.js +1 -0
- package/node_modules/@prisma/client/.prisma/client/edge.d.ts +1 -0
- package/node_modules/@prisma/client/.prisma/client/edge.js +396 -0
- package/node_modules/@prisma/client/.prisma/client/index-browser.js +385 -0
- package/node_modules/@prisma/client/.prisma/client/index.d.ts +26996 -0
- package/node_modules/@prisma/client/.prisma/client/index.js +421 -0
- package/node_modules/@prisma/client/.prisma/client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/node_modules/@prisma/client/.prisma/client/package.json +97 -0
- package/node_modules/@prisma/client/.prisma/client/query_engine-windows.dll.node +0 -0
- package/node_modules/@prisma/client/.prisma/client/schema.prisma +296 -0
- package/node_modules/@prisma/client/.prisma/client/wasm.d.ts +1 -0
- package/node_modules/@prisma/client/.prisma/client/wasm.js +385 -0
- package/node_modules/@prisma/client/package.json +3 -2
- package/package.json +2 -1
- package/prisma/migrations/20260515000000_add_workspace_preview_target/migration.sql +2 -0
- package/prisma/migrations/20260518000000_add_team_run_collaboration/migration.sql +150 -0
- package/prisma/migrations/20260522000000_add_team_member_session_policy/migration.sql +2 -0
- package/prisma/migrations/20260526000000_add_team_run_main_and_dedicated_workspaces/migration.sql +21 -0
- package/prisma/schema.prisma +147 -1
- package/dist/web/assets/AgentDemoPage-ClnGPAV9.js +0 -1
- package/dist/web/assets/NotificationSettingsPage-y3vhVgPv.js +0 -1
- package/dist/web/assets/ProjectKanbanPage-BddzfZRV.js +0 -87
- package/dist/web/assets/ProjectSettingsPage-B6xhbziO.js +0 -2
- package/dist/web/assets/circle-alert-EUyZcWhp.js +0 -1
- package/dist/web/assets/folder-picker-CUbhsnhi.js +0 -1
- package/dist/web/assets/index-BGvfX18x.css +0 -1
- package/dist/web/assets/index-CHN8jahE.js +0 -13
- package/dist/web/assets/loader-circle-BHzDVpxt.js +0 -1
- package/dist/web/assets/modal-D_AU4URz.js +0 -1
- package/dist/web/assets/use-projects-Bcd5hIOY.js +0 -1
|
@@ -0,0 +1,1688 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { AgentType, TaskStatus } from '../../types/index.js';
|
|
8
|
+
import { TeamLockService } from '../team-lock.service.js';
|
|
9
|
+
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-tower-team-scheduler-'));
|
|
10
|
+
const dbPath = path.join(testDir, 'test.db');
|
|
11
|
+
process.env.AGENT_TOWER_DATABASE_URL = `file:${dbPath}`;
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = path.dirname(__filename);
|
|
14
|
+
const serverRoot = path.resolve(__dirname, '../../..');
|
|
15
|
+
const schemaPath = path.join(serverRoot, 'prisma/schema.prisma');
|
|
16
|
+
let TeamSchedulerService;
|
|
17
|
+
let prisma;
|
|
18
|
+
let workRequestSequence = 0;
|
|
19
|
+
let createdWorkspaceSequence = 0;
|
|
20
|
+
const readOnlyCapabilities = {
|
|
21
|
+
readRoom: true,
|
|
22
|
+
postRoomMessage: true,
|
|
23
|
+
mentionMembers: true,
|
|
24
|
+
stopMemberWork: false,
|
|
25
|
+
markReadyForReview: false,
|
|
26
|
+
readFiles: true,
|
|
27
|
+
writeFiles: false,
|
|
28
|
+
runCommands: false,
|
|
29
|
+
readDiff: true,
|
|
30
|
+
mergeWorkspace: false,
|
|
31
|
+
};
|
|
32
|
+
const writeCapabilities = {
|
|
33
|
+
...readOnlyCapabilities,
|
|
34
|
+
writeFiles: true,
|
|
35
|
+
};
|
|
36
|
+
const commandCapabilities = {
|
|
37
|
+
...readOnlyCapabilities,
|
|
38
|
+
runCommands: true,
|
|
39
|
+
};
|
|
40
|
+
function stringifyJson(value) {
|
|
41
|
+
return JSON.stringify(value);
|
|
42
|
+
}
|
|
43
|
+
async function createTask(title = 'Team scheduler task', status = TaskStatus.TODO) {
|
|
44
|
+
const project = await prisma.project.create({
|
|
45
|
+
data: {
|
|
46
|
+
name: `${title} project`,
|
|
47
|
+
repoPath: testDir,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const task = await prisma.task.create({
|
|
51
|
+
data: {
|
|
52
|
+
title,
|
|
53
|
+
projectId: project.id,
|
|
54
|
+
status,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
return { project, task };
|
|
58
|
+
}
|
|
59
|
+
async function createTeamRunFixture(options = {}) {
|
|
60
|
+
const { project, task } = await createTask('Team scheduler task', options.taskStatus);
|
|
61
|
+
const teamRun = await prisma.teamRun.create({
|
|
62
|
+
data: {
|
|
63
|
+
taskId: task.id,
|
|
64
|
+
mode: 'AUTO',
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const workspace = options.withWorkspace === false
|
|
68
|
+
? null
|
|
69
|
+
: await prisma.workspace.create({
|
|
70
|
+
data: {
|
|
71
|
+
taskId: task.id,
|
|
72
|
+
branchName: `team-${teamRun.id}`,
|
|
73
|
+
worktreePath: path.join(testDir, `workspace-${teamRun.id}`),
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const capabilities = options.memberCapabilities ?? [readOnlyCapabilities];
|
|
77
|
+
const members = [];
|
|
78
|
+
for (const [index, memberCapabilities] of capabilities.entries()) {
|
|
79
|
+
members.push(await prisma.teamMember.create({
|
|
80
|
+
data: {
|
|
81
|
+
teamRunId: teamRun.id,
|
|
82
|
+
presetId: null,
|
|
83
|
+
name: `Member ${index + 1}`,
|
|
84
|
+
aliases: stringifyJson([`member-${index + 1}`]),
|
|
85
|
+
providerId: `provider-${index + 1}`,
|
|
86
|
+
rolePrompt: `Role ${index + 1}`,
|
|
87
|
+
capabilities: stringifyJson(memberCapabilities),
|
|
88
|
+
workspacePolicy: options.workspacePolicies?.[index] ?? 'shared',
|
|
89
|
+
triggerPolicy: 'MENTION_ONLY',
|
|
90
|
+
sessionPolicy: options.sessionPolicies?.[index] ?? 'new_per_request',
|
|
91
|
+
avatar: null,
|
|
92
|
+
},
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
return { project, task, teamRun, workspace, members };
|
|
96
|
+
}
|
|
97
|
+
async function createWorkRequest(options) {
|
|
98
|
+
return prisma.workRequest.create({
|
|
99
|
+
data: {
|
|
100
|
+
teamRunId: options.teamRunId,
|
|
101
|
+
requesterMemberId: null,
|
|
102
|
+
requesterType: 'user',
|
|
103
|
+
targetMemberId: options.targetMemberId,
|
|
104
|
+
triggerMessageId: options.triggerMessageId ?? `message-${Math.random().toString(16).slice(2)}`,
|
|
105
|
+
instruction: options.instruction ?? 'Please do the work',
|
|
106
|
+
ifBusy: options.ifBusy ?? 'queue',
|
|
107
|
+
cancelQueued: options.cancelQueued ?? false,
|
|
108
|
+
status: options.status ?? 'QUEUED',
|
|
109
|
+
createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, workRequestSequence++)),
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function createProviderLookup() {
|
|
114
|
+
return vi.fn((providerId) => ({
|
|
115
|
+
id: providerId,
|
|
116
|
+
name: providerId,
|
|
117
|
+
agentType: AgentType.CODEX,
|
|
118
|
+
env: {},
|
|
119
|
+
config: {},
|
|
120
|
+
isDefault: false,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
function createWorkspaceServiceMock() {
|
|
124
|
+
return {
|
|
125
|
+
create: vi.fn(async (taskId) => {
|
|
126
|
+
const sequence = createdWorkspaceSequence++;
|
|
127
|
+
return prisma.workspace.create({
|
|
128
|
+
data: {
|
|
129
|
+
taskId,
|
|
130
|
+
branchName: `team-shared-${sequence}`,
|
|
131
|
+
worktreePath: path.join(testDir, `created-workspace-${sequence}`),
|
|
132
|
+
status: 'ACTIVE',
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function createSessionManagerMock(options = {}) {
|
|
139
|
+
return {
|
|
140
|
+
create: vi.fn(async (workspaceId, agentType, prompt, variant = 'DEFAULT', providerId) => prisma.session.create({
|
|
141
|
+
data: {
|
|
142
|
+
workspaceId,
|
|
143
|
+
agentType,
|
|
144
|
+
variant,
|
|
145
|
+
providerId: providerId ?? null,
|
|
146
|
+
prompt,
|
|
147
|
+
status: 'PENDING',
|
|
148
|
+
},
|
|
149
|
+
})),
|
|
150
|
+
start: vi.fn(async (sessionId) => {
|
|
151
|
+
if (options.failStart) {
|
|
152
|
+
throw new Error('session start failed');
|
|
153
|
+
}
|
|
154
|
+
return prisma.session.update({
|
|
155
|
+
where: { id: sessionId },
|
|
156
|
+
data: { status: 'RUNNING' },
|
|
157
|
+
});
|
|
158
|
+
}),
|
|
159
|
+
startFollowUp: vi.fn(async (sessionId) => {
|
|
160
|
+
if (options.failStart) {
|
|
161
|
+
throw new Error('session start failed');
|
|
162
|
+
}
|
|
163
|
+
return prisma.session.update({
|
|
164
|
+
where: { id: sessionId },
|
|
165
|
+
data: { status: 'RUNNING' },
|
|
166
|
+
});
|
|
167
|
+
}),
|
|
168
|
+
stop: vi.fn(async (sessionId) => prisma.session.update({
|
|
169
|
+
where: { id: sessionId },
|
|
170
|
+
data: { status: 'CANCELLED' },
|
|
171
|
+
})),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function createDeferred() {
|
|
175
|
+
let resolve;
|
|
176
|
+
let reject;
|
|
177
|
+
const promise = new Promise((promiseResolve, promiseReject) => {
|
|
178
|
+
resolve = promiseResolve;
|
|
179
|
+
reject = promiseReject;
|
|
180
|
+
});
|
|
181
|
+
return { promise, resolve, reject };
|
|
182
|
+
}
|
|
183
|
+
async function waitForCondition(predicate, timeoutMs = 1000) {
|
|
184
|
+
const startedAt = Date.now();
|
|
185
|
+
while (!predicate()) {
|
|
186
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
187
|
+
throw new Error('Timed out waiting for condition');
|
|
188
|
+
}
|
|
189
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
describe('TeamSchedulerService', () => {
|
|
193
|
+
let service;
|
|
194
|
+
let lockService;
|
|
195
|
+
beforeAll(async () => {
|
|
196
|
+
execFileSync('pnpm', ['exec', 'prisma', 'db', 'push', '--skip-generate', `--schema=${schemaPath}`], {
|
|
197
|
+
cwd: serverRoot,
|
|
198
|
+
env: { ...process.env, AGENT_TOWER_DATABASE_URL: `file:${dbPath}` },
|
|
199
|
+
stdio: 'pipe',
|
|
200
|
+
});
|
|
201
|
+
const serviceModule = await import('../team-scheduler.service.js');
|
|
202
|
+
const utilsModule = await import('../../utils/index.js');
|
|
203
|
+
TeamSchedulerService = serviceModule.TeamSchedulerService;
|
|
204
|
+
prisma = utilsModule.prisma;
|
|
205
|
+
});
|
|
206
|
+
beforeEach(async () => {
|
|
207
|
+
vi.restoreAllMocks();
|
|
208
|
+
workRequestSequence = 0;
|
|
209
|
+
createdWorkspaceSequence = 0;
|
|
210
|
+
lockService = new TeamLockService();
|
|
211
|
+
service = new TeamSchedulerService(lockService);
|
|
212
|
+
await prisma.agentInvocation.deleteMany();
|
|
213
|
+
await prisma.workRequest.deleteMany();
|
|
214
|
+
await prisma.roomMessage.deleteMany();
|
|
215
|
+
await prisma.teamMember.deleteMany();
|
|
216
|
+
await prisma.teamRun.deleteMany();
|
|
217
|
+
await prisma.teamTemplateMember.deleteMany();
|
|
218
|
+
await prisma.teamTemplate.deleteMany();
|
|
219
|
+
await prisma.memberPreset.deleteMany();
|
|
220
|
+
await prisma.session.deleteMany();
|
|
221
|
+
await prisma.workspace.deleteMany();
|
|
222
|
+
await prisma.task.deleteMany();
|
|
223
|
+
await prisma.project.deleteMany();
|
|
224
|
+
});
|
|
225
|
+
afterAll(async () => {
|
|
226
|
+
vi.restoreAllMocks();
|
|
227
|
+
await prisma.$disconnect();
|
|
228
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
229
|
+
});
|
|
230
|
+
it('approves a pending WorkRequest into the queue', async () => {
|
|
231
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
232
|
+
const request = await createWorkRequest({
|
|
233
|
+
teamRunId: teamRun.id,
|
|
234
|
+
targetMemberId: members[0].id,
|
|
235
|
+
status: 'PENDING_APPROVAL',
|
|
236
|
+
});
|
|
237
|
+
const approved = await service.approveWorkRequest(request.id);
|
|
238
|
+
expect(approved.status).toBe('QUEUED');
|
|
239
|
+
});
|
|
240
|
+
it('approves a pending WorkRequest and immediately starts eligible queued work', async () => {
|
|
241
|
+
const { teamRun, members } = await createTeamRunFixture({ withWorkspace: false });
|
|
242
|
+
const request = await createWorkRequest({
|
|
243
|
+
teamRunId: teamRun.id,
|
|
244
|
+
targetMemberId: members[0].id,
|
|
245
|
+
status: 'PENDING_APPROVAL',
|
|
246
|
+
});
|
|
247
|
+
service = new TeamSchedulerService(lockService, {
|
|
248
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
249
|
+
sessionManager: createSessionManagerMock(),
|
|
250
|
+
getProviderById: createProviderLookup(),
|
|
251
|
+
});
|
|
252
|
+
const result = await service.approveWorkRequestAndStartNext(request.id);
|
|
253
|
+
expect(result.workRequest).toMatchObject({
|
|
254
|
+
id: request.id,
|
|
255
|
+
status: 'QUEUED',
|
|
256
|
+
});
|
|
257
|
+
expect(result.startedInvocations).toHaveLength(1);
|
|
258
|
+
expect(result.startedInvocations[0]).toMatchObject({
|
|
259
|
+
teamRunId: teamRun.id,
|
|
260
|
+
workRequestId: request.id,
|
|
261
|
+
memberId: members[0].id,
|
|
262
|
+
status: 'RUNNING',
|
|
263
|
+
sessionId: expect.any(String),
|
|
264
|
+
});
|
|
265
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
266
|
+
status: 'STARTED',
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
it('returns a clear error when approving a non-pending WorkRequest', async () => {
|
|
270
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
271
|
+
const request = await createWorkRequest({
|
|
272
|
+
teamRunId: teamRun.id,
|
|
273
|
+
targetMemberId: members[0].id,
|
|
274
|
+
status: 'QUEUED',
|
|
275
|
+
});
|
|
276
|
+
await expect(service.approveWorkRequest(request.id)).rejects.toMatchObject({
|
|
277
|
+
code: 'INVALID_STATE_TRANSITION',
|
|
278
|
+
statusCode: 400,
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
it('rejects a pending WorkRequest', async () => {
|
|
282
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
283
|
+
const request = await createWorkRequest({
|
|
284
|
+
teamRunId: teamRun.id,
|
|
285
|
+
targetMemberId: members[0].id,
|
|
286
|
+
status: 'PENDING_APPROVAL',
|
|
287
|
+
});
|
|
288
|
+
const rejected = await service.rejectWorkRequest(request.id);
|
|
289
|
+
expect(rejected.status).toBe('REJECTED');
|
|
290
|
+
});
|
|
291
|
+
it('returns a clear error when rejecting a non-pending WorkRequest', async () => {
|
|
292
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
293
|
+
const request = await createWorkRequest({
|
|
294
|
+
teamRunId: teamRun.id,
|
|
295
|
+
targetMemberId: members[0].id,
|
|
296
|
+
status: 'QUEUED',
|
|
297
|
+
});
|
|
298
|
+
await expect(service.rejectWorkRequest(request.id)).rejects.toMatchObject({
|
|
299
|
+
code: 'INVALID_STATE_TRANSITION',
|
|
300
|
+
statusCode: 400,
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
it('cancels pending and queued WorkRequests', async () => {
|
|
304
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
305
|
+
const pending = await createWorkRequest({
|
|
306
|
+
teamRunId: teamRun.id,
|
|
307
|
+
targetMemberId: members[0].id,
|
|
308
|
+
status: 'PENDING_APPROVAL',
|
|
309
|
+
});
|
|
310
|
+
const queued = await createWorkRequest({
|
|
311
|
+
teamRunId: teamRun.id,
|
|
312
|
+
targetMemberId: members[0].id,
|
|
313
|
+
status: 'QUEUED',
|
|
314
|
+
});
|
|
315
|
+
await expect(service.cancelWorkRequest(pending.id)).resolves.toMatchObject({ status: 'CANCELLED' });
|
|
316
|
+
await expect(service.cancelWorkRequest(queued.id)).resolves.toMatchObject({ status: 'CANCELLED' });
|
|
317
|
+
});
|
|
318
|
+
it('does not cancel a started WorkRequest', async () => {
|
|
319
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
320
|
+
const request = await createWorkRequest({
|
|
321
|
+
teamRunId: teamRun.id,
|
|
322
|
+
targetMemberId: members[0].id,
|
|
323
|
+
status: 'STARTED',
|
|
324
|
+
});
|
|
325
|
+
await expect(service.cancelWorkRequest(request.id)).rejects.toMatchObject({
|
|
326
|
+
code: 'INVALID_STATE_TRANSITION',
|
|
327
|
+
statusCode: 400,
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
it('does not let a stale cancel overwrite a WorkRequest that was started before the conditional write', async () => {
|
|
331
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
332
|
+
const request = await createWorkRequest({
|
|
333
|
+
teamRunId: teamRun.id,
|
|
334
|
+
targetMemberId: members[0].id,
|
|
335
|
+
status: 'QUEUED',
|
|
336
|
+
});
|
|
337
|
+
const originalTransaction = prisma.$transaction.bind(prisma);
|
|
338
|
+
const transactionSpy = vi.spyOn(prisma, '$transaction');
|
|
339
|
+
transactionSpy.mockImplementationOnce(async (arg, ...rest) => {
|
|
340
|
+
await prisma.workRequest.update({
|
|
341
|
+
where: { id: request.id },
|
|
342
|
+
data: { status: 'STARTED' },
|
|
343
|
+
});
|
|
344
|
+
return originalTransaction(arg, ...rest);
|
|
345
|
+
});
|
|
346
|
+
await expect(service.cancelWorkRequest(request.id)).rejects.toMatchObject({
|
|
347
|
+
code: 'INVALID_STATE_TRANSITION',
|
|
348
|
+
statusCode: 400,
|
|
349
|
+
message: expect.stringContaining('STARTED'),
|
|
350
|
+
});
|
|
351
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
352
|
+
status: 'STARTED',
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
it('starts queued work by creating a queued AgentInvocation without workspace or session creation', async () => {
|
|
356
|
+
const { teamRun, members } = await createTeamRunFixture({ withWorkspace: false });
|
|
357
|
+
const request = await createWorkRequest({
|
|
358
|
+
teamRunId: teamRun.id,
|
|
359
|
+
targetMemberId: members[0].id,
|
|
360
|
+
status: 'QUEUED',
|
|
361
|
+
});
|
|
362
|
+
const workspaceCountBefore = await prisma.workspace.count();
|
|
363
|
+
const sessionCountBefore = await prisma.session.count();
|
|
364
|
+
const invocations = await service.startNext(teamRun.id);
|
|
365
|
+
expect(invocations).toHaveLength(1);
|
|
366
|
+
expect(invocations[0]).toMatchObject({
|
|
367
|
+
teamRunId: teamRun.id,
|
|
368
|
+
workRequestId: request.id,
|
|
369
|
+
memberId: members[0].id,
|
|
370
|
+
workspaceId: null,
|
|
371
|
+
sessionId: null,
|
|
372
|
+
status: 'QUEUED',
|
|
373
|
+
});
|
|
374
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
375
|
+
status: 'STARTED',
|
|
376
|
+
});
|
|
377
|
+
await expect(prisma.workspace.count()).resolves.toBe(workspaceCountBefore);
|
|
378
|
+
await expect(prisma.session.count()).resolves.toBe(sessionCountBefore);
|
|
379
|
+
});
|
|
380
|
+
it('does not start new work for a member with an active invocation', async () => {
|
|
381
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
382
|
+
await prisma.agentInvocation.create({
|
|
383
|
+
data: {
|
|
384
|
+
teamRunId: teamRun.id,
|
|
385
|
+
workRequestId: 'existing-work-request',
|
|
386
|
+
memberId: members[0].id,
|
|
387
|
+
status: 'RUNNING',
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
const request = await createWorkRequest({
|
|
391
|
+
teamRunId: teamRun.id,
|
|
392
|
+
targetMemberId: members[0].id,
|
|
393
|
+
});
|
|
394
|
+
await expect(service.startNext(teamRun.id)).resolves.toEqual([]);
|
|
395
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
396
|
+
status: 'QUEUED',
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
it('starts only one queued WorkRequest for the same member in a single batch', async () => {
|
|
400
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
401
|
+
const first = await createWorkRequest({
|
|
402
|
+
teamRunId: teamRun.id,
|
|
403
|
+
targetMemberId: members[0].id,
|
|
404
|
+
instruction: 'First',
|
|
405
|
+
});
|
|
406
|
+
const second = await createWorkRequest({
|
|
407
|
+
teamRunId: teamRun.id,
|
|
408
|
+
targetMemberId: members[0].id,
|
|
409
|
+
instruction: 'Second',
|
|
410
|
+
});
|
|
411
|
+
const invocations = await service.startNext(teamRun.id);
|
|
412
|
+
expect(invocations).toHaveLength(1);
|
|
413
|
+
expect(invocations[0].workRequestId).toBe(first.id);
|
|
414
|
+
await expect(prisma.workRequest.findUnique({ where: { id: first.id } })).resolves.toMatchObject({
|
|
415
|
+
status: 'STARTED',
|
|
416
|
+
});
|
|
417
|
+
await expect(prisma.workRequest.findUnique({ where: { id: second.id } })).resolves.toMatchObject({
|
|
418
|
+
status: 'QUEUED',
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
it('does not double-start a read-only member during concurrent startNext calls', async () => {
|
|
422
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
423
|
+
const first = await createWorkRequest({
|
|
424
|
+
teamRunId: teamRun.id,
|
|
425
|
+
targetMemberId: members[0].id,
|
|
426
|
+
instruction: 'First',
|
|
427
|
+
});
|
|
428
|
+
const second = await createWorkRequest({
|
|
429
|
+
teamRunId: teamRun.id,
|
|
430
|
+
targetMemberId: members[0].id,
|
|
431
|
+
instruction: 'Second',
|
|
432
|
+
});
|
|
433
|
+
const anotherService = new TeamSchedulerService(lockService);
|
|
434
|
+
await Promise.all([
|
|
435
|
+
service.startNext(teamRun.id),
|
|
436
|
+
anotherService.startNext(teamRun.id),
|
|
437
|
+
]);
|
|
438
|
+
await expect(prisma.agentInvocation.count({
|
|
439
|
+
where: {
|
|
440
|
+
teamRunId: teamRun.id,
|
|
441
|
+
memberId: members[0].id,
|
|
442
|
+
status: { in: ['QUEUED', 'RUNNING', 'SESSION_ENDED', 'WAITING_ROOM_REPLY'] },
|
|
443
|
+
},
|
|
444
|
+
})).resolves.toBe(1);
|
|
445
|
+
const reloaded = await prisma.workRequest.findMany({
|
|
446
|
+
where: { id: { in: [first.id, second.id] } },
|
|
447
|
+
orderBy: { createdAt: 'asc' },
|
|
448
|
+
});
|
|
449
|
+
expect(reloaded.map((request) => request.status).sort()).toEqual(['QUEUED', 'STARTED']);
|
|
450
|
+
});
|
|
451
|
+
it('starts only one member when two members need the shared workspace write lock', async () => {
|
|
452
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
453
|
+
memberCapabilities: [writeCapabilities, writeCapabilities],
|
|
454
|
+
});
|
|
455
|
+
const first = await createWorkRequest({
|
|
456
|
+
teamRunId: teamRun.id,
|
|
457
|
+
targetMemberId: members[0].id,
|
|
458
|
+
});
|
|
459
|
+
const second = await createWorkRequest({
|
|
460
|
+
teamRunId: teamRun.id,
|
|
461
|
+
targetMemberId: members[1].id,
|
|
462
|
+
});
|
|
463
|
+
const invocations = await service.startNext(teamRun.id);
|
|
464
|
+
expect(invocations).toHaveLength(1);
|
|
465
|
+
expect(invocations[0].workRequestId).toBe(first.id);
|
|
466
|
+
await expect(prisma.workRequest.findUnique({ where: { id: first.id } })).resolves.toMatchObject({
|
|
467
|
+
status: 'STARTED',
|
|
468
|
+
});
|
|
469
|
+
await expect(prisma.workRequest.findUnique({ where: { id: second.id } })).resolves.toMatchObject({
|
|
470
|
+
status: 'QUEUED',
|
|
471
|
+
});
|
|
472
|
+
expect(lockService.listLocks()).toEqual([
|
|
473
|
+
{ key: `workspace:task:${task.id}:write`, ownerId: invocations[0].id },
|
|
474
|
+
]);
|
|
475
|
+
});
|
|
476
|
+
it('uses a task proxy lock for shared write work when no active workspace exists', async () => {
|
|
477
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
478
|
+
memberCapabilities: [writeCapabilities, writeCapabilities],
|
|
479
|
+
withWorkspace: false,
|
|
480
|
+
});
|
|
481
|
+
const first = await createWorkRequest({
|
|
482
|
+
teamRunId: teamRun.id,
|
|
483
|
+
targetMemberId: members[0].id,
|
|
484
|
+
});
|
|
485
|
+
const second = await createWorkRequest({
|
|
486
|
+
teamRunId: teamRun.id,
|
|
487
|
+
targetMemberId: members[1].id,
|
|
488
|
+
});
|
|
489
|
+
const workspaceCountBefore = await prisma.workspace.count();
|
|
490
|
+
const sessionCountBefore = await prisma.session.count();
|
|
491
|
+
const invocations = await service.startNext(teamRun.id);
|
|
492
|
+
expect(invocations).toHaveLength(1);
|
|
493
|
+
expect(invocations[0]).toMatchObject({
|
|
494
|
+
workRequestId: first.id,
|
|
495
|
+
workspaceId: null,
|
|
496
|
+
sessionId: null,
|
|
497
|
+
status: 'QUEUED',
|
|
498
|
+
});
|
|
499
|
+
expect(lockService.listLocks()).toEqual([
|
|
500
|
+
{ key: `workspace:task:${task.id}:write`, ownerId: invocations[0].id },
|
|
501
|
+
]);
|
|
502
|
+
await expect(prisma.workRequest.findUnique({ where: { id: first.id } })).resolves.toMatchObject({
|
|
503
|
+
status: 'STARTED',
|
|
504
|
+
});
|
|
505
|
+
await expect(prisma.workRequest.findUnique({ where: { id: second.id } })).resolves.toMatchObject({
|
|
506
|
+
status: 'QUEUED',
|
|
507
|
+
});
|
|
508
|
+
await expect(prisma.workspace.count()).resolves.toBe(workspaceCountBefore);
|
|
509
|
+
await expect(prisma.session.count()).resolves.toBe(sessionCountBefore);
|
|
510
|
+
});
|
|
511
|
+
it('uses a task proxy lock for shared command work when no active workspace exists', async () => {
|
|
512
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
513
|
+
memberCapabilities: [commandCapabilities, commandCapabilities],
|
|
514
|
+
withWorkspace: false,
|
|
515
|
+
});
|
|
516
|
+
const first = await createWorkRequest({
|
|
517
|
+
teamRunId: teamRun.id,
|
|
518
|
+
targetMemberId: members[0].id,
|
|
519
|
+
});
|
|
520
|
+
const second = await createWorkRequest({
|
|
521
|
+
teamRunId: teamRun.id,
|
|
522
|
+
targetMemberId: members[1].id,
|
|
523
|
+
});
|
|
524
|
+
const workspaceCountBefore = await prisma.workspace.count();
|
|
525
|
+
const sessionCountBefore = await prisma.session.count();
|
|
526
|
+
const invocations = await service.startNext(teamRun.id);
|
|
527
|
+
expect(invocations).toHaveLength(1);
|
|
528
|
+
expect(invocations[0]).toMatchObject({
|
|
529
|
+
workRequestId: first.id,
|
|
530
|
+
workspaceId: null,
|
|
531
|
+
sessionId: null,
|
|
532
|
+
status: 'QUEUED',
|
|
533
|
+
});
|
|
534
|
+
expect(lockService.listLocks()).toEqual([
|
|
535
|
+
{ key: `workspace:task:${task.id}:command`, ownerId: invocations[0].id },
|
|
536
|
+
]);
|
|
537
|
+
await expect(prisma.workRequest.findUnique({ where: { id: first.id } })).resolves.toMatchObject({
|
|
538
|
+
status: 'STARTED',
|
|
539
|
+
});
|
|
540
|
+
await expect(prisma.workRequest.findUnique({ where: { id: second.id } })).resolves.toMatchObject({
|
|
541
|
+
status: 'QUEUED',
|
|
542
|
+
});
|
|
543
|
+
await expect(prisma.workspace.count()).resolves.toBe(workspaceCountBefore);
|
|
544
|
+
await expect(prisma.session.count()).resolves.toBe(sessionCountBefore);
|
|
545
|
+
});
|
|
546
|
+
it('starts read-only work for different members in parallel', async () => {
|
|
547
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
548
|
+
memberCapabilities: [readOnlyCapabilities, readOnlyCapabilities],
|
|
549
|
+
});
|
|
550
|
+
await createWorkRequest({
|
|
551
|
+
teamRunId: teamRun.id,
|
|
552
|
+
targetMemberId: members[0].id,
|
|
553
|
+
});
|
|
554
|
+
await createWorkRequest({
|
|
555
|
+
teamRunId: teamRun.id,
|
|
556
|
+
targetMemberId: members[1].id,
|
|
557
|
+
});
|
|
558
|
+
const invocations = await service.startNext(teamRun.id);
|
|
559
|
+
expect(invocations).toHaveLength(2);
|
|
560
|
+
await expect(prisma.workRequest.count({
|
|
561
|
+
where: { teamRunId: teamRun.id, status: 'STARTED' },
|
|
562
|
+
})).resolves.toBe(2);
|
|
563
|
+
});
|
|
564
|
+
it('leaves work queued when an external owner holds the required lock', async () => {
|
|
565
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
566
|
+
memberCapabilities: [writeCapabilities],
|
|
567
|
+
});
|
|
568
|
+
const request = await createWorkRequest({
|
|
569
|
+
teamRunId: teamRun.id,
|
|
570
|
+
targetMemberId: members[0].id,
|
|
571
|
+
});
|
|
572
|
+
expect(lockService.acquire('external-owner', [`workspace:task:${task.id}:write`])).toBe(true);
|
|
573
|
+
await expect(service.startNext(teamRun.id)).resolves.toEqual([]);
|
|
574
|
+
await expect(prisma.agentInvocation.count()).resolves.toBe(0);
|
|
575
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
576
|
+
status: 'QUEUED',
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
it('releases acquired locks when invocation creation fails', async () => {
|
|
580
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
581
|
+
memberCapabilities: [writeCapabilities],
|
|
582
|
+
});
|
|
583
|
+
const request = await createWorkRequest({
|
|
584
|
+
teamRunId: teamRun.id,
|
|
585
|
+
targetMemberId: members[0].id,
|
|
586
|
+
});
|
|
587
|
+
const transactionSpy = vi.spyOn(prisma, '$transaction').mockRejectedValueOnce(new Error('transaction failed'));
|
|
588
|
+
await expect(service.startNext(teamRun.id)).rejects.toThrow('transaction failed');
|
|
589
|
+
transactionSpy.mockRestore();
|
|
590
|
+
expect(lockService.listLocks().filter((lock) => lock.ownerId.startsWith('pending:'))).toEqual([]);
|
|
591
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
592
|
+
await expect(prisma.agentInvocation.count()).resolves.toBe(0);
|
|
593
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
594
|
+
status: 'QUEUED',
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
it('marks cancel_current_and_start plans as requiring a future stop integration', async () => {
|
|
598
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
599
|
+
await prisma.agentInvocation.create({
|
|
600
|
+
data: {
|
|
601
|
+
teamRunId: teamRun.id,
|
|
602
|
+
workRequestId: 'existing-work-request',
|
|
603
|
+
memberId: members[0].id,
|
|
604
|
+
status: 'RUNNING',
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
const request = await createWorkRequest({
|
|
608
|
+
teamRunId: teamRun.id,
|
|
609
|
+
targetMemberId: members[0].id,
|
|
610
|
+
ifBusy: 'cancel_current_and_start',
|
|
611
|
+
});
|
|
612
|
+
await expect(service.planNext(teamRun.id)).resolves.toEqual([
|
|
613
|
+
expect.objectContaining({
|
|
614
|
+
workRequestId: request.id,
|
|
615
|
+
canStart: false,
|
|
616
|
+
blockedReason: 'member_busy',
|
|
617
|
+
requiresStopCurrent: true,
|
|
618
|
+
}),
|
|
619
|
+
]);
|
|
620
|
+
});
|
|
621
|
+
it('starts dedicated members in child workspaces without shared workspace locks', async () => {
|
|
622
|
+
const { workspace: mainWorkspace, teamRun, members } = await createTeamRunFixture({
|
|
623
|
+
memberCapabilities: [writeCapabilities, writeCapabilities],
|
|
624
|
+
workspacePolicies: ['dedicated', 'dedicated'],
|
|
625
|
+
});
|
|
626
|
+
const first = await createWorkRequest({
|
|
627
|
+
teamRunId: teamRun.id,
|
|
628
|
+
targetMemberId: members[0].id,
|
|
629
|
+
});
|
|
630
|
+
const second = await createWorkRequest({
|
|
631
|
+
teamRunId: teamRun.id,
|
|
632
|
+
targetMemberId: members[1].id,
|
|
633
|
+
});
|
|
634
|
+
const childByMemberId = new Map();
|
|
635
|
+
const workspaceService = {
|
|
636
|
+
create: vi.fn(),
|
|
637
|
+
getOrCreateMainWorkspace: vi.fn(async () => mainWorkspace),
|
|
638
|
+
getOrCreateDedicatedWorkspace: vi.fn(async (_teamRunId, memberId) => {
|
|
639
|
+
const existing = childByMemberId.get(memberId);
|
|
640
|
+
if (existing) {
|
|
641
|
+
return existing;
|
|
642
|
+
}
|
|
643
|
+
const workspace = await prisma.workspace.create({
|
|
644
|
+
data: {
|
|
645
|
+
taskId: teamRun.taskId,
|
|
646
|
+
parentWorkspaceId: mainWorkspace.id,
|
|
647
|
+
ownerMemberId: memberId,
|
|
648
|
+
branchName: `dedicated-${memberId.slice(0, 8)}`,
|
|
649
|
+
worktreePath: path.join(testDir, `dedicated-${memberId}`),
|
|
650
|
+
status: 'ACTIVE',
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
childByMemberId.set(memberId, workspace);
|
|
654
|
+
return workspace;
|
|
655
|
+
}),
|
|
656
|
+
};
|
|
657
|
+
service = new TeamSchedulerService(lockService, {
|
|
658
|
+
workspaceService,
|
|
659
|
+
sessionManager: createSessionManagerMock(),
|
|
660
|
+
getProviderById: createProviderLookup(),
|
|
661
|
+
});
|
|
662
|
+
await expect(service.planNext(teamRun.id)).resolves.toEqual([
|
|
663
|
+
expect.objectContaining({
|
|
664
|
+
workRequestId: first.id,
|
|
665
|
+
canStart: true,
|
|
666
|
+
lockKeys: [],
|
|
667
|
+
workspaceId: null,
|
|
668
|
+
}),
|
|
669
|
+
expect.objectContaining({
|
|
670
|
+
workRequestId: second.id,
|
|
671
|
+
canStart: true,
|
|
672
|
+
lockKeys: [],
|
|
673
|
+
workspaceId: null,
|
|
674
|
+
}),
|
|
675
|
+
]);
|
|
676
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
677
|
+
expect(invocations).toHaveLength(2);
|
|
678
|
+
expect(new Set(invocations.map((invocation) => invocation.workspaceId)).size).toBe(2);
|
|
679
|
+
expect(new Set(invocations.map((invocation) => invocation.workRequestId))).toEqual(new Set([
|
|
680
|
+
first.id,
|
|
681
|
+
second.id,
|
|
682
|
+
]));
|
|
683
|
+
expect(workspaceService.getOrCreateDedicatedWorkspace).toHaveBeenCalledTimes(2);
|
|
684
|
+
expect(workspaceService.create).not.toHaveBeenCalled();
|
|
685
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
686
|
+
const childWorkspaces = await prisma.workspace.findMany({
|
|
687
|
+
where: { parentWorkspaceId: mainWorkspace.id },
|
|
688
|
+
orderBy: { ownerMemberId: 'asc' },
|
|
689
|
+
});
|
|
690
|
+
expect(childWorkspaces).toHaveLength(2);
|
|
691
|
+
expect(new Set(childWorkspaces.map((workspace) => workspace.ownerMemberId))).toEqual(new Set([
|
|
692
|
+
members[0].id,
|
|
693
|
+
members[1].id,
|
|
694
|
+
]));
|
|
695
|
+
});
|
|
696
|
+
it('records the dedicated child workspace for queued no-session invocations', async () => {
|
|
697
|
+
const { workspace: mainWorkspace, teamRun, members } = await createTeamRunFixture({
|
|
698
|
+
memberCapabilities: [writeCapabilities],
|
|
699
|
+
workspacePolicies: ['dedicated'],
|
|
700
|
+
});
|
|
701
|
+
const request = await createWorkRequest({
|
|
702
|
+
teamRunId: teamRun.id,
|
|
703
|
+
targetMemberId: members[0].id,
|
|
704
|
+
});
|
|
705
|
+
const childWorkspace = await prisma.workspace.create({
|
|
706
|
+
data: {
|
|
707
|
+
taskId: teamRun.taskId,
|
|
708
|
+
parentWorkspaceId: mainWorkspace.id,
|
|
709
|
+
ownerMemberId: members[0].id,
|
|
710
|
+
branchName: 'dedicated-queued',
|
|
711
|
+
worktreePath: path.join(testDir, 'dedicated-queued'),
|
|
712
|
+
status: 'ACTIVE',
|
|
713
|
+
},
|
|
714
|
+
});
|
|
715
|
+
const workspaceService = {
|
|
716
|
+
create: vi.fn(),
|
|
717
|
+
getOrCreateMainWorkspace: vi.fn(async () => mainWorkspace),
|
|
718
|
+
getOrCreateDedicatedWorkspace: vi.fn(async () => childWorkspace),
|
|
719
|
+
};
|
|
720
|
+
service = new TeamSchedulerService(lockService, {
|
|
721
|
+
workspaceService,
|
|
722
|
+
sessionManager: createSessionManagerMock(),
|
|
723
|
+
getProviderById: createProviderLookup(),
|
|
724
|
+
});
|
|
725
|
+
const invocations = await service.startNext(teamRun.id);
|
|
726
|
+
expect(invocations).toHaveLength(1);
|
|
727
|
+
expect(invocations[0]).toMatchObject({
|
|
728
|
+
workRequestId: request.id,
|
|
729
|
+
memberId: members[0].id,
|
|
730
|
+
workspaceId: childWorkspace.id,
|
|
731
|
+
sessionId: null,
|
|
732
|
+
status: 'QUEUED',
|
|
733
|
+
});
|
|
734
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
735
|
+
expect(workspaceService.getOrCreateDedicatedWorkspace).toHaveBeenCalledWith(teamRun.id, members[0].id);
|
|
736
|
+
await expect(prisma.agentInvocation.findUnique({ where: { id: invocations[0].id } })).resolves.toMatchObject({
|
|
737
|
+
workspaceId: childWorkspace.id,
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
it('cancels only queued requests for the same member when cancelQueued is set', async () => {
|
|
741
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
742
|
+
const first = await createWorkRequest({
|
|
743
|
+
teamRunId: teamRun.id,
|
|
744
|
+
targetMemberId: members[0].id,
|
|
745
|
+
cancelQueued: true,
|
|
746
|
+
});
|
|
747
|
+
const second = await createWorkRequest({
|
|
748
|
+
teamRunId: teamRun.id,
|
|
749
|
+
targetMemberId: members[0].id,
|
|
750
|
+
});
|
|
751
|
+
const started = await createWorkRequest({
|
|
752
|
+
teamRunId: teamRun.id,
|
|
753
|
+
targetMemberId: members[0].id,
|
|
754
|
+
status: 'STARTED',
|
|
755
|
+
});
|
|
756
|
+
await service.startNext(teamRun.id);
|
|
757
|
+
await expect(prisma.workRequest.findUnique({ where: { id: first.id } })).resolves.toMatchObject({
|
|
758
|
+
status: 'STARTED',
|
|
759
|
+
});
|
|
760
|
+
await expect(prisma.workRequest.findUnique({ where: { id: second.id } })).resolves.toMatchObject({
|
|
761
|
+
status: 'CANCELLED',
|
|
762
|
+
});
|
|
763
|
+
await expect(prisma.workRequest.findUnique({ where: { id: started.id } })).resolves.toMatchObject({
|
|
764
|
+
status: 'STARTED',
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
it('starts a shared member by creating the task shared workspace, session, and running invocation', async () => {
|
|
768
|
+
const { task, teamRun, members } = await createTeamRunFixture({ withWorkspace: false });
|
|
769
|
+
const request = await createWorkRequest({
|
|
770
|
+
teamRunId: teamRun.id,
|
|
771
|
+
targetMemberId: members[0].id,
|
|
772
|
+
instruction: 'Implement the shared work',
|
|
773
|
+
});
|
|
774
|
+
const workspaceService = createWorkspaceServiceMock();
|
|
775
|
+
const sessionManager = createSessionManagerMock();
|
|
776
|
+
service = new TeamSchedulerService(lockService, {
|
|
777
|
+
workspaceService,
|
|
778
|
+
sessionManager,
|
|
779
|
+
getProviderById: createProviderLookup(),
|
|
780
|
+
});
|
|
781
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
782
|
+
expect(workspaceService.create).toHaveBeenCalledWith(task.id);
|
|
783
|
+
expect(sessionManager.create).toHaveBeenCalledWith(invocations[0].workspaceId, AgentType.CODEX, 'Role 1\n\nTask:\nImplement the shared work', 'DEFAULT', members[0].providerId);
|
|
784
|
+
expect(sessionManager.start).toHaveBeenCalledWith(invocations[0].sessionId);
|
|
785
|
+
expect(sessionManager.startFollowUp).not.toHaveBeenCalled();
|
|
786
|
+
expect(invocations).toHaveLength(1);
|
|
787
|
+
expect(invocations[0]).toMatchObject({
|
|
788
|
+
teamRunId: teamRun.id,
|
|
789
|
+
workRequestId: request.id,
|
|
790
|
+
memberId: members[0].id,
|
|
791
|
+
workspaceId: expect.any(String),
|
|
792
|
+
sessionId: expect.any(String),
|
|
793
|
+
status: 'RUNNING',
|
|
794
|
+
});
|
|
795
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
796
|
+
status: 'STARTED',
|
|
797
|
+
});
|
|
798
|
+
await expect(prisma.session.findUnique({ where: { id: invocations[0].sessionId } })).resolves.toMatchObject({
|
|
799
|
+
workspaceId: invocations[0].workspaceId,
|
|
800
|
+
providerId: members[0].providerId,
|
|
801
|
+
status: 'RUNNING',
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
it('builds session prompt attachment context from the trigger RoomMessage attachmentIds', async () => {
|
|
805
|
+
const { task, teamRun, members } = await createTeamRunFixture({ withWorkspace: false });
|
|
806
|
+
const attachment = await prisma.attachment.create({
|
|
807
|
+
data: {
|
|
808
|
+
originalName: 'reference.png',
|
|
809
|
+
mimeType: 'image/png',
|
|
810
|
+
sizeBytes: 256,
|
|
811
|
+
storagePath: path.join(testDir, 'reference.png'),
|
|
812
|
+
hash: 'scheduler-attachment-context-hash',
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
const message = await prisma.roomMessage.create({
|
|
816
|
+
data: {
|
|
817
|
+
teamRunId: teamRun.id,
|
|
818
|
+
senderType: 'user',
|
|
819
|
+
kind: 'work_request',
|
|
820
|
+
content: 'Use this reference',
|
|
821
|
+
mentions: stringifyJson([{ memberId: members[0].id, label: 'Member 1' }]),
|
|
822
|
+
workRequestIds: stringifyJson([]),
|
|
823
|
+
artifactRefs: stringifyJson([]),
|
|
824
|
+
attachmentIds: stringifyJson([attachment.id]),
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
await createWorkRequest({
|
|
828
|
+
teamRunId: teamRun.id,
|
|
829
|
+
targetMemberId: members[0].id,
|
|
830
|
+
instruction: 'Use this reference',
|
|
831
|
+
triggerMessageId: message.id,
|
|
832
|
+
});
|
|
833
|
+
const workspaceService = createWorkspaceServiceMock();
|
|
834
|
+
const sessionManager = createSessionManagerMock();
|
|
835
|
+
service = new TeamSchedulerService(lockService, {
|
|
836
|
+
workspaceService,
|
|
837
|
+
sessionManager,
|
|
838
|
+
getProviderById: createProviderLookup(),
|
|
839
|
+
});
|
|
840
|
+
await service.startNextSessions(teamRun.id);
|
|
841
|
+
expect(workspaceService.create).toHaveBeenCalledWith(task.id);
|
|
842
|
+
expect(sessionManager.create).toHaveBeenCalledWith(expect.any(String), AgentType.CODEX, `Role 1\n\nTask:\nUse this reference\n\nAttachments:\n`, 'DEFAULT', members[0].providerId);
|
|
843
|
+
});
|
|
844
|
+
it('does not duplicate session prompt attachment context when the WorkRequest instruction already includes the storage path', async () => {
|
|
845
|
+
const { task, teamRun, members } = await createTeamRunFixture({ withWorkspace: false });
|
|
846
|
+
const attachment = await prisma.attachment.create({
|
|
847
|
+
data: {
|
|
848
|
+
originalName: 'reference.png',
|
|
849
|
+
mimeType: 'image/png',
|
|
850
|
+
sizeBytes: 256,
|
|
851
|
+
storagePath: path.join(testDir, 'reference-dedup.png'),
|
|
852
|
+
hash: 'scheduler-attachment-context-dedup-hash',
|
|
853
|
+
},
|
|
854
|
+
});
|
|
855
|
+
const instruction = `Use this reference\n\n`;
|
|
856
|
+
const message = await prisma.roomMessage.create({
|
|
857
|
+
data: {
|
|
858
|
+
teamRunId: teamRun.id,
|
|
859
|
+
senderType: 'user',
|
|
860
|
+
kind: 'work_request',
|
|
861
|
+
content: instruction,
|
|
862
|
+
mentions: stringifyJson([{ memberId: members[0].id, label: 'Member 1' }]),
|
|
863
|
+
workRequestIds: stringifyJson([]),
|
|
864
|
+
artifactRefs: stringifyJson([]),
|
|
865
|
+
attachmentIds: stringifyJson([attachment.id]),
|
|
866
|
+
},
|
|
867
|
+
});
|
|
868
|
+
await createWorkRequest({
|
|
869
|
+
teamRunId: teamRun.id,
|
|
870
|
+
targetMemberId: members[0].id,
|
|
871
|
+
instruction,
|
|
872
|
+
triggerMessageId: message.id,
|
|
873
|
+
});
|
|
874
|
+
const workspaceService = createWorkspaceServiceMock();
|
|
875
|
+
const sessionManager = createSessionManagerMock();
|
|
876
|
+
service = new TeamSchedulerService(lockService, {
|
|
877
|
+
workspaceService,
|
|
878
|
+
sessionManager,
|
|
879
|
+
getProviderById: createProviderLookup(),
|
|
880
|
+
});
|
|
881
|
+
await service.startNextSessions(teamRun.id);
|
|
882
|
+
expect(workspaceService.create).toHaveBeenCalledWith(task.id);
|
|
883
|
+
expect(sessionManager.create).toHaveBeenCalledWith(expect.any(String), AgentType.CODEX, `Role 1\n\nTask:\n${instruction}`, 'DEFAULT', members[0].providerId);
|
|
884
|
+
expect(sessionManager.create.mock.calls[0]?.[2]).not.toContain('Attachments:');
|
|
885
|
+
});
|
|
886
|
+
it('starts resume_last members with executor resume context while keeping a new Tower session and invocation', async () => {
|
|
887
|
+
const { workspace, teamRun, members } = await createTeamRunFixture({
|
|
888
|
+
sessionPolicies: ['resume_last'],
|
|
889
|
+
});
|
|
890
|
+
const previousRequest = await createWorkRequest({
|
|
891
|
+
teamRunId: teamRun.id,
|
|
892
|
+
targetMemberId: members[0].id,
|
|
893
|
+
status: 'STARTED',
|
|
894
|
+
instruction: 'Previous work',
|
|
895
|
+
});
|
|
896
|
+
const previousSession = await prisma.session.create({
|
|
897
|
+
data: {
|
|
898
|
+
workspaceId: workspace.id,
|
|
899
|
+
agentType: AgentType.CODEX,
|
|
900
|
+
providerId: members[0].providerId,
|
|
901
|
+
prompt: 'previous prompt',
|
|
902
|
+
status: 'COMPLETED',
|
|
903
|
+
logSnapshot: JSON.stringify({ sessionId: 'agent-native-session-1', entries: [] }),
|
|
904
|
+
createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
905
|
+
updatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
906
|
+
},
|
|
907
|
+
});
|
|
908
|
+
await prisma.agentInvocation.create({
|
|
909
|
+
data: {
|
|
910
|
+
teamRunId: teamRun.id,
|
|
911
|
+
workRequestId: previousRequest.id,
|
|
912
|
+
memberId: members[0].id,
|
|
913
|
+
workspaceId: workspace.id,
|
|
914
|
+
sessionId: previousSession.id,
|
|
915
|
+
status: 'COMPLETED',
|
|
916
|
+
createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
917
|
+
updatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
918
|
+
},
|
|
919
|
+
});
|
|
920
|
+
const nextRequest = await createWorkRequest({
|
|
921
|
+
teamRunId: teamRun.id,
|
|
922
|
+
targetMemberId: members[0].id,
|
|
923
|
+
instruction: 'Continue with context',
|
|
924
|
+
});
|
|
925
|
+
const sessionManager = createSessionManagerMock();
|
|
926
|
+
service = new TeamSchedulerService(lockService, {
|
|
927
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
928
|
+
sessionManager,
|
|
929
|
+
getProviderById: createProviderLookup(),
|
|
930
|
+
});
|
|
931
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
932
|
+
expect(invocations).toHaveLength(1);
|
|
933
|
+
expect(invocations[0]).toMatchObject({
|
|
934
|
+
workRequestId: nextRequest.id,
|
|
935
|
+
memberId: members[0].id,
|
|
936
|
+
sessionId: expect.any(String),
|
|
937
|
+
status: 'RUNNING',
|
|
938
|
+
});
|
|
939
|
+
expect(invocations[0].sessionId).not.toBe(previousSession.id);
|
|
940
|
+
expect(sessionManager.create).toHaveBeenCalledWith(workspace.id, AgentType.CODEX, 'Role 1\n\nTask:\nContinue with context', 'DEFAULT', members[0].providerId);
|
|
941
|
+
expect(sessionManager.startFollowUp).toHaveBeenCalledWith(invocations[0].sessionId, previousSession.id);
|
|
942
|
+
expect(sessionManager.start).not.toHaveBeenCalled();
|
|
943
|
+
await expect(prisma.session.findUnique({ where: { id: invocations[0].sessionId } })).resolves.toMatchObject({
|
|
944
|
+
workspaceId: workspace.id,
|
|
945
|
+
providerId: members[0].providerId,
|
|
946
|
+
prompt: 'Role 1\n\nTask:\nContinue with context',
|
|
947
|
+
status: 'RUNNING',
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
it('starts new_per_request members without resuming previous native context', async () => {
|
|
951
|
+
const { workspace, teamRun, members } = await createTeamRunFixture({
|
|
952
|
+
sessionPolicies: ['new_per_request'],
|
|
953
|
+
});
|
|
954
|
+
const previousRequest = await createWorkRequest({
|
|
955
|
+
teamRunId: teamRun.id,
|
|
956
|
+
targetMemberId: members[0].id,
|
|
957
|
+
status: 'STARTED',
|
|
958
|
+
instruction: 'Previous work',
|
|
959
|
+
});
|
|
960
|
+
const previousSession = await prisma.session.create({
|
|
961
|
+
data: {
|
|
962
|
+
workspaceId: workspace.id,
|
|
963
|
+
agentType: AgentType.CODEX,
|
|
964
|
+
providerId: members[0].providerId,
|
|
965
|
+
prompt: 'previous prompt',
|
|
966
|
+
status: 'COMPLETED',
|
|
967
|
+
logSnapshot: JSON.stringify({ sessionId: 'agent-native-session-1', entries: [] }),
|
|
968
|
+
},
|
|
969
|
+
});
|
|
970
|
+
await prisma.agentInvocation.create({
|
|
971
|
+
data: {
|
|
972
|
+
teamRunId: teamRun.id,
|
|
973
|
+
workRequestId: previousRequest.id,
|
|
974
|
+
memberId: members[0].id,
|
|
975
|
+
workspaceId: workspace.id,
|
|
976
|
+
sessionId: previousSession.id,
|
|
977
|
+
status: 'COMPLETED',
|
|
978
|
+
},
|
|
979
|
+
});
|
|
980
|
+
const nextRequest = await createWorkRequest({
|
|
981
|
+
teamRunId: teamRun.id,
|
|
982
|
+
targetMemberId: members[0].id,
|
|
983
|
+
instruction: 'Fresh request',
|
|
984
|
+
});
|
|
985
|
+
const sessionManager = createSessionManagerMock();
|
|
986
|
+
service = new TeamSchedulerService(lockService, {
|
|
987
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
988
|
+
sessionManager,
|
|
989
|
+
getProviderById: createProviderLookup(),
|
|
990
|
+
});
|
|
991
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
992
|
+
expect(invocations).toHaveLength(1);
|
|
993
|
+
expect(invocations[0]).toMatchObject({
|
|
994
|
+
workRequestId: nextRequest.id,
|
|
995
|
+
memberId: members[0].id,
|
|
996
|
+
sessionId: expect.any(String),
|
|
997
|
+
status: 'RUNNING',
|
|
998
|
+
});
|
|
999
|
+
expect(invocations[0].sessionId).not.toBe(previousSession.id);
|
|
1000
|
+
expect(sessionManager.start).toHaveBeenCalledWith(invocations[0].sessionId);
|
|
1001
|
+
expect(sessionManager.startFollowUp).not.toHaveBeenCalled();
|
|
1002
|
+
});
|
|
1003
|
+
it('does not resume_last from a different member even when native context exists in the same workspace', async () => {
|
|
1004
|
+
const { workspace, teamRun, members } = await createTeamRunFixture({
|
|
1005
|
+
memberCapabilities: [readOnlyCapabilities, readOnlyCapabilities],
|
|
1006
|
+
sessionPolicies: ['resume_last', 'resume_last'],
|
|
1007
|
+
});
|
|
1008
|
+
const otherMemberRequest = await createWorkRequest({
|
|
1009
|
+
teamRunId: teamRun.id,
|
|
1010
|
+
targetMemberId: members[1].id,
|
|
1011
|
+
status: 'STARTED',
|
|
1012
|
+
instruction: 'Other member previous work',
|
|
1013
|
+
});
|
|
1014
|
+
const otherMemberSession = await prisma.session.create({
|
|
1015
|
+
data: {
|
|
1016
|
+
workspaceId: workspace.id,
|
|
1017
|
+
agentType: AgentType.CODEX,
|
|
1018
|
+
providerId: members[1].providerId,
|
|
1019
|
+
prompt: 'other member prompt',
|
|
1020
|
+
status: 'COMPLETED',
|
|
1021
|
+
logSnapshot: JSON.stringify({ sessionId: 'agent-native-session-2', entries: [] }),
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
await prisma.agentInvocation.create({
|
|
1025
|
+
data: {
|
|
1026
|
+
teamRunId: teamRun.id,
|
|
1027
|
+
workRequestId: otherMemberRequest.id,
|
|
1028
|
+
memberId: members[1].id,
|
|
1029
|
+
workspaceId: workspace.id,
|
|
1030
|
+
sessionId: otherMemberSession.id,
|
|
1031
|
+
status: 'COMPLETED',
|
|
1032
|
+
},
|
|
1033
|
+
});
|
|
1034
|
+
const nextRequest = await createWorkRequest({
|
|
1035
|
+
teamRunId: teamRun.id,
|
|
1036
|
+
targetMemberId: members[0].id,
|
|
1037
|
+
instruction: 'Member 1 fresh work',
|
|
1038
|
+
});
|
|
1039
|
+
const sessionManager = createSessionManagerMock();
|
|
1040
|
+
service = new TeamSchedulerService(lockService, {
|
|
1041
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1042
|
+
sessionManager,
|
|
1043
|
+
getProviderById: createProviderLookup(),
|
|
1044
|
+
});
|
|
1045
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
1046
|
+
expect(invocations).toHaveLength(1);
|
|
1047
|
+
expect(invocations[0]).toMatchObject({
|
|
1048
|
+
workRequestId: nextRequest.id,
|
|
1049
|
+
memberId: members[0].id,
|
|
1050
|
+
status: 'RUNNING',
|
|
1051
|
+
});
|
|
1052
|
+
expect(sessionManager.start).toHaveBeenCalledWith(invocations[0].sessionId);
|
|
1053
|
+
expect(sessionManager.startFollowUp).not.toHaveBeenCalled();
|
|
1054
|
+
});
|
|
1055
|
+
it('resume_last only resumes native context from the same member in the selected workspace', async () => {
|
|
1056
|
+
const { task, workspace: selectedWorkspace, teamRun, members } = await createTeamRunFixture({
|
|
1057
|
+
sessionPolicies: ['resume_last'],
|
|
1058
|
+
});
|
|
1059
|
+
const otherWorkspace = await prisma.workspace.create({
|
|
1060
|
+
data: {
|
|
1061
|
+
taskId: task.id,
|
|
1062
|
+
parentWorkspaceId: selectedWorkspace.id,
|
|
1063
|
+
branchName: 'other-workspace',
|
|
1064
|
+
worktreePath: path.join(testDir, 'other-workspace'),
|
|
1065
|
+
status: 'ACTIVE',
|
|
1066
|
+
},
|
|
1067
|
+
});
|
|
1068
|
+
const previousRequest = await createWorkRequest({
|
|
1069
|
+
teamRunId: teamRun.id,
|
|
1070
|
+
targetMemberId: members[0].id,
|
|
1071
|
+
status: 'STARTED',
|
|
1072
|
+
instruction: 'Previous work in another workspace',
|
|
1073
|
+
});
|
|
1074
|
+
const otherWorkspaceSession = await prisma.session.create({
|
|
1075
|
+
data: {
|
|
1076
|
+
workspaceId: otherWorkspace.id,
|
|
1077
|
+
agentType: AgentType.CODEX,
|
|
1078
|
+
providerId: members[0].providerId,
|
|
1079
|
+
prompt: 'previous prompt in other workspace',
|
|
1080
|
+
status: 'COMPLETED',
|
|
1081
|
+
logSnapshot: JSON.stringify({ sessionId: 'agent-native-session-other-workspace', entries: [] }),
|
|
1082
|
+
createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
1083
|
+
updatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
1084
|
+
},
|
|
1085
|
+
});
|
|
1086
|
+
await prisma.agentInvocation.create({
|
|
1087
|
+
data: {
|
|
1088
|
+
teamRunId: teamRun.id,
|
|
1089
|
+
workRequestId: previousRequest.id,
|
|
1090
|
+
memberId: members[0].id,
|
|
1091
|
+
workspaceId: otherWorkspace.id,
|
|
1092
|
+
sessionId: otherWorkspaceSession.id,
|
|
1093
|
+
status: 'COMPLETED',
|
|
1094
|
+
createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
1095
|
+
updatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
1096
|
+
},
|
|
1097
|
+
});
|
|
1098
|
+
const nextRequest = await createWorkRequest({
|
|
1099
|
+
teamRunId: teamRun.id,
|
|
1100
|
+
targetMemberId: members[0].id,
|
|
1101
|
+
instruction: 'Continue in selected workspace',
|
|
1102
|
+
});
|
|
1103
|
+
const sessionManager = createSessionManagerMock();
|
|
1104
|
+
service = new TeamSchedulerService(lockService, {
|
|
1105
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1106
|
+
sessionManager,
|
|
1107
|
+
getProviderById: createProviderLookup(),
|
|
1108
|
+
});
|
|
1109
|
+
const firstRun = await service.startNextSessions(teamRun.id);
|
|
1110
|
+
expect(firstRun).toHaveLength(1);
|
|
1111
|
+
expect(firstRun[0]).toMatchObject({
|
|
1112
|
+
workRequestId: nextRequest.id,
|
|
1113
|
+
workspaceId: selectedWorkspace.id,
|
|
1114
|
+
status: 'RUNNING',
|
|
1115
|
+
});
|
|
1116
|
+
expect(sessionManager.start).toHaveBeenCalledWith(firstRun[0].sessionId);
|
|
1117
|
+
expect(sessionManager.startFollowUp).not.toHaveBeenCalled();
|
|
1118
|
+
await prisma.session.update({
|
|
1119
|
+
where: { id: firstRun[0].sessionId },
|
|
1120
|
+
data: {
|
|
1121
|
+
status: 'COMPLETED',
|
|
1122
|
+
logSnapshot: JSON.stringify({ sessionId: 'agent-native-session-selected-workspace', entries: [] }),
|
|
1123
|
+
},
|
|
1124
|
+
});
|
|
1125
|
+
await prisma.agentInvocation.update({
|
|
1126
|
+
where: { id: firstRun[0].id },
|
|
1127
|
+
data: { status: 'COMPLETED' },
|
|
1128
|
+
});
|
|
1129
|
+
const followUpRequest = await createWorkRequest({
|
|
1130
|
+
teamRunId: teamRun.id,
|
|
1131
|
+
targetMemberId: members[0].id,
|
|
1132
|
+
instruction: 'Continue again in selected workspace',
|
|
1133
|
+
});
|
|
1134
|
+
sessionManager.start.mockClear();
|
|
1135
|
+
sessionManager.startFollowUp.mockClear();
|
|
1136
|
+
const secondRun = await service.startNextSessions(teamRun.id);
|
|
1137
|
+
expect(secondRun).toHaveLength(1);
|
|
1138
|
+
expect(secondRun[0]).toMatchObject({
|
|
1139
|
+
workRequestId: followUpRequest.id,
|
|
1140
|
+
workspaceId: selectedWorkspace.id,
|
|
1141
|
+
status: 'RUNNING',
|
|
1142
|
+
});
|
|
1143
|
+
expect(sessionManager.startFollowUp).toHaveBeenCalledWith(secondRun[0].sessionId, firstRun[0].sessionId);
|
|
1144
|
+
expect(sessionManager.start).not.toHaveBeenCalled();
|
|
1145
|
+
});
|
|
1146
|
+
it('falls back to a normal session start for resume_last members without previous native context', async () => {
|
|
1147
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
1148
|
+
sessionPolicies: ['resume_last'],
|
|
1149
|
+
});
|
|
1150
|
+
await createWorkRequest({
|
|
1151
|
+
teamRunId: teamRun.id,
|
|
1152
|
+
targetMemberId: members[0].id,
|
|
1153
|
+
instruction: 'Fresh work',
|
|
1154
|
+
});
|
|
1155
|
+
const sessionManager = createSessionManagerMock();
|
|
1156
|
+
service = new TeamSchedulerService(lockService, {
|
|
1157
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1158
|
+
sessionManager,
|
|
1159
|
+
getProviderById: createProviderLookup(),
|
|
1160
|
+
});
|
|
1161
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
1162
|
+
expect(invocations).toHaveLength(1);
|
|
1163
|
+
expect(sessionManager.start).toHaveBeenCalledWith(invocations[0].sessionId);
|
|
1164
|
+
expect(sessionManager.startFollowUp).not.toHaveBeenCalled();
|
|
1165
|
+
});
|
|
1166
|
+
it('stops member work by cancelling no-session active work, queued requests, and releasing locks', async () => {
|
|
1167
|
+
const { task, workspace, teamRun, members } = await createTeamRunFixture({
|
|
1168
|
+
memberCapabilities: [writeCapabilities, writeCapabilities],
|
|
1169
|
+
});
|
|
1170
|
+
const activeRequest = await createWorkRequest({
|
|
1171
|
+
teamRunId: teamRun.id,
|
|
1172
|
+
targetMemberId: members[0].id,
|
|
1173
|
+
status: 'STARTED',
|
|
1174
|
+
});
|
|
1175
|
+
const activeInvocation = await prisma.agentInvocation.create({
|
|
1176
|
+
data: {
|
|
1177
|
+
teamRunId: teamRun.id,
|
|
1178
|
+
workRequestId: activeRequest.id,
|
|
1179
|
+
memberId: members[0].id,
|
|
1180
|
+
workspaceId: workspace.id,
|
|
1181
|
+
sessionId: null,
|
|
1182
|
+
status: 'QUEUED',
|
|
1183
|
+
},
|
|
1184
|
+
});
|
|
1185
|
+
expect(lockService.acquire(activeInvocation.id, [`workspace:task:${task.id}:write`])).toBe(true);
|
|
1186
|
+
const pending = await createWorkRequest({
|
|
1187
|
+
teamRunId: teamRun.id,
|
|
1188
|
+
targetMemberId: members[0].id,
|
|
1189
|
+
status: 'PENDING_APPROVAL',
|
|
1190
|
+
});
|
|
1191
|
+
const queued = await createWorkRequest({
|
|
1192
|
+
teamRunId: teamRun.id,
|
|
1193
|
+
targetMemberId: members[0].id,
|
|
1194
|
+
status: 'QUEUED',
|
|
1195
|
+
});
|
|
1196
|
+
const otherMemberQueued = await createWorkRequest({
|
|
1197
|
+
teamRunId: teamRun.id,
|
|
1198
|
+
targetMemberId: members[1].id,
|
|
1199
|
+
status: 'QUEUED',
|
|
1200
|
+
});
|
|
1201
|
+
service = new TeamSchedulerService(lockService, {
|
|
1202
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1203
|
+
sessionManager: createSessionManagerMock(),
|
|
1204
|
+
getProviderById: createProviderLookup(),
|
|
1205
|
+
});
|
|
1206
|
+
const result = await service.stopMemberWork(teamRun.id, members[0].id, { cancelQueued: true });
|
|
1207
|
+
expect(result.stoppedSessionIds).toEqual([]);
|
|
1208
|
+
expect(result.cancelledInvocationIds).toEqual([activeInvocation.id]);
|
|
1209
|
+
expect(new Set(result.cancelledWorkRequestIds)).toEqual(new Set([
|
|
1210
|
+
activeRequest.id,
|
|
1211
|
+
pending.id,
|
|
1212
|
+
queued.id,
|
|
1213
|
+
]));
|
|
1214
|
+
expect(result.startedInvocations).toHaveLength(1);
|
|
1215
|
+
expect(result.startedInvocations[0]).toMatchObject({
|
|
1216
|
+
workRequestId: otherMemberQueued.id,
|
|
1217
|
+
memberId: members[1].id,
|
|
1218
|
+
status: 'RUNNING',
|
|
1219
|
+
});
|
|
1220
|
+
expect(lockService.listLocks()).toEqual([
|
|
1221
|
+
{ key: `workspace:task:${task.id}:write`, ownerId: result.startedInvocations[0].id },
|
|
1222
|
+
]);
|
|
1223
|
+
await expect(prisma.agentInvocation.findUnique({ where: { id: activeInvocation.id } })).resolves.toMatchObject({
|
|
1224
|
+
status: 'CANCELLED',
|
|
1225
|
+
nextRoomReplyReminderAt: null,
|
|
1226
|
+
});
|
|
1227
|
+
const reloadedRequests = await prisma.workRequest.findMany({
|
|
1228
|
+
where: { id: { in: [activeRequest.id, pending.id, queued.id, otherMemberQueued.id] } },
|
|
1229
|
+
orderBy: { createdAt: 'asc' },
|
|
1230
|
+
});
|
|
1231
|
+
expect(reloadedRequests.map((request) => [request.id, request.status])).toEqual([
|
|
1232
|
+
[activeRequest.id, 'CANCELLED'],
|
|
1233
|
+
[pending.id, 'CANCELLED'],
|
|
1234
|
+
[queued.id, 'CANCELLED'],
|
|
1235
|
+
[otherMemberQueued.id, 'STARTED'],
|
|
1236
|
+
]);
|
|
1237
|
+
});
|
|
1238
|
+
it('stops session-backed member work through SessionManager.stop and then starts queued work', async () => {
|
|
1239
|
+
const { task, workspace, teamRun, members } = await createTeamRunFixture({
|
|
1240
|
+
memberCapabilities: [writeCapabilities, writeCapabilities],
|
|
1241
|
+
});
|
|
1242
|
+
const activeRequest = await createWorkRequest({
|
|
1243
|
+
teamRunId: teamRun.id,
|
|
1244
|
+
targetMemberId: members[0].id,
|
|
1245
|
+
status: 'STARTED',
|
|
1246
|
+
});
|
|
1247
|
+
const session = await prisma.session.create({
|
|
1248
|
+
data: {
|
|
1249
|
+
workspaceId: workspace.id,
|
|
1250
|
+
agentType: AgentType.CODEX,
|
|
1251
|
+
providerId: members[0].providerId,
|
|
1252
|
+
prompt: 'Do active work',
|
|
1253
|
+
status: 'RUNNING',
|
|
1254
|
+
},
|
|
1255
|
+
});
|
|
1256
|
+
const activeInvocation = await prisma.agentInvocation.create({
|
|
1257
|
+
data: {
|
|
1258
|
+
teamRunId: teamRun.id,
|
|
1259
|
+
workRequestId: activeRequest.id,
|
|
1260
|
+
memberId: members[0].id,
|
|
1261
|
+
workspaceId: workspace.id,
|
|
1262
|
+
sessionId: session.id,
|
|
1263
|
+
status: 'WAITING_ROOM_REPLY',
|
|
1264
|
+
roomReplyReminderCount: 1,
|
|
1265
|
+
nextRoomReplyReminderAt: new Date(Date.UTC(2026, 0, 1, 0, 1, 0)),
|
|
1266
|
+
},
|
|
1267
|
+
});
|
|
1268
|
+
expect(lockService.acquire(activeInvocation.id, [`workspace:task:${task.id}:write`])).toBe(true);
|
|
1269
|
+
const nextRequest = await createWorkRequest({
|
|
1270
|
+
teamRunId: teamRun.id,
|
|
1271
|
+
targetMemberId: members[1].id,
|
|
1272
|
+
status: 'QUEUED',
|
|
1273
|
+
});
|
|
1274
|
+
const sessionManager = createSessionManagerMock();
|
|
1275
|
+
sessionManager.stop.mockImplementation(async (sessionId) => {
|
|
1276
|
+
await prisma.agentInvocation.update({
|
|
1277
|
+
where: { id: activeInvocation.id },
|
|
1278
|
+
data: {
|
|
1279
|
+
status: 'CANCELLED',
|
|
1280
|
+
nextRoomReplyReminderAt: null,
|
|
1281
|
+
},
|
|
1282
|
+
});
|
|
1283
|
+
lockService.releaseByOwner(activeInvocation.id);
|
|
1284
|
+
return prisma.session.update({
|
|
1285
|
+
where: { id: sessionId },
|
|
1286
|
+
data: { status: 'CANCELLED' },
|
|
1287
|
+
});
|
|
1288
|
+
});
|
|
1289
|
+
service = new TeamSchedulerService(lockService, {
|
|
1290
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1291
|
+
sessionManager,
|
|
1292
|
+
getProviderById: createProviderLookup(),
|
|
1293
|
+
});
|
|
1294
|
+
const result = await service.stopMemberWork(teamRun.id, members[0].id);
|
|
1295
|
+
expect(sessionManager.stop).toHaveBeenCalledWith(session.id);
|
|
1296
|
+
expect(result.stoppedSessionIds).toEqual([session.id]);
|
|
1297
|
+
expect(result.cancelledInvocationIds).toEqual([]);
|
|
1298
|
+
expect(result.cancelledWorkRequestIds).toEqual([]);
|
|
1299
|
+
expect(result.startedInvocations).toHaveLength(1);
|
|
1300
|
+
expect(result.startedInvocations[0]).toMatchObject({
|
|
1301
|
+
workRequestId: nextRequest.id,
|
|
1302
|
+
memberId: members[1].id,
|
|
1303
|
+
status: 'RUNNING',
|
|
1304
|
+
});
|
|
1305
|
+
await expect(prisma.session.findUnique({ where: { id: session.id } })).resolves.toMatchObject({
|
|
1306
|
+
status: 'CANCELLED',
|
|
1307
|
+
});
|
|
1308
|
+
await expect(prisma.agentInvocation.findUnique({ where: { id: activeInvocation.id } })).resolves.toMatchObject({
|
|
1309
|
+
status: 'CANCELLED',
|
|
1310
|
+
nextRoomReplyReminderAt: null,
|
|
1311
|
+
});
|
|
1312
|
+
expect(lockService.listLocks()).toEqual([
|
|
1313
|
+
{ key: `workspace:task:${task.id}:write`, ownerId: result.startedInvocations[0].id },
|
|
1314
|
+
]);
|
|
1315
|
+
});
|
|
1316
|
+
it('does not start queued work when stopping a member with no active invocation and no queue cancellation', async () => {
|
|
1317
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
1318
|
+
memberCapabilities: [readOnlyCapabilities, readOnlyCapabilities],
|
|
1319
|
+
});
|
|
1320
|
+
const otherMemberQueued = await createWorkRequest({
|
|
1321
|
+
teamRunId: teamRun.id,
|
|
1322
|
+
targetMemberId: members[1].id,
|
|
1323
|
+
status: 'QUEUED',
|
|
1324
|
+
});
|
|
1325
|
+
service = new TeamSchedulerService(lockService, {
|
|
1326
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1327
|
+
sessionManager: createSessionManagerMock(),
|
|
1328
|
+
getProviderById: createProviderLookup(),
|
|
1329
|
+
});
|
|
1330
|
+
const result = await service.stopMemberWork(teamRun.id, members[0].id);
|
|
1331
|
+
expect(result).toEqual({
|
|
1332
|
+
stoppedSessionIds: [],
|
|
1333
|
+
cancelledInvocationIds: [],
|
|
1334
|
+
cancelledWorkRequestIds: [],
|
|
1335
|
+
startedInvocations: [],
|
|
1336
|
+
});
|
|
1337
|
+
await expect(prisma.workRequest.findUnique({ where: { id: otherMemberQueued.id } })).resolves.toMatchObject({
|
|
1338
|
+
status: 'QUEUED',
|
|
1339
|
+
});
|
|
1340
|
+
await expect(prisma.agentInvocation.count({ where: { teamRunId: teamRun.id } })).resolves.toBe(0);
|
|
1341
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
1342
|
+
});
|
|
1343
|
+
it('starts none-policy members in the shared workspace without workspace write or command locks', async () => {
|
|
1344
|
+
const { workspace, teamRun, members } = await createTeamRunFixture({
|
|
1345
|
+
memberCapabilities: [commandCapabilities, readOnlyCapabilities],
|
|
1346
|
+
workspacePolicies: ['none', 'shared'],
|
|
1347
|
+
});
|
|
1348
|
+
const noneRequest = await createWorkRequest({
|
|
1349
|
+
teamRunId: teamRun.id,
|
|
1350
|
+
targetMemberId: members[0].id,
|
|
1351
|
+
});
|
|
1352
|
+
await createWorkRequest({
|
|
1353
|
+
teamRunId: teamRun.id,
|
|
1354
|
+
targetMemberId: members[1].id,
|
|
1355
|
+
});
|
|
1356
|
+
const sessionManager = createSessionManagerMock();
|
|
1357
|
+
service = new TeamSchedulerService(lockService, {
|
|
1358
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1359
|
+
sessionManager,
|
|
1360
|
+
getProviderById: createProviderLookup(),
|
|
1361
|
+
});
|
|
1362
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
1363
|
+
expect(invocations).toHaveLength(2);
|
|
1364
|
+
expect(invocations.map((invocation) => invocation.workspaceId)).toEqual([workspace.id, workspace.id]);
|
|
1365
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
1366
|
+
const sameMemberRequest = await createWorkRequest({
|
|
1367
|
+
teamRunId: teamRun.id,
|
|
1368
|
+
targetMemberId: members[0].id,
|
|
1369
|
+
});
|
|
1370
|
+
await expect(service.startNextSessions(teamRun.id)).resolves.toEqual([]);
|
|
1371
|
+
await expect(prisma.workRequest.findUnique({ where: { id: noneRequest.id } })).resolves.toMatchObject({
|
|
1372
|
+
status: 'STARTED',
|
|
1373
|
+
});
|
|
1374
|
+
await expect(prisma.workRequest.findUnique({ where: { id: sameMemberRequest.id } })).resolves.toMatchObject({
|
|
1375
|
+
status: 'QUEUED',
|
|
1376
|
+
});
|
|
1377
|
+
});
|
|
1378
|
+
it('starts none-policy write and command members on the shared workspace without workspace locks', async () => {
|
|
1379
|
+
const { workspace, teamRun, members } = await createTeamRunFixture({
|
|
1380
|
+
memberCapabilities: [
|
|
1381
|
+
{ ...writeCapabilities, runCommands: true },
|
|
1382
|
+
writeCapabilities,
|
|
1383
|
+
],
|
|
1384
|
+
workspacePolicies: ['none', 'shared'],
|
|
1385
|
+
});
|
|
1386
|
+
const noneRequest = await createWorkRequest({
|
|
1387
|
+
teamRunId: teamRun.id,
|
|
1388
|
+
targetMemberId: members[0].id,
|
|
1389
|
+
});
|
|
1390
|
+
const sharedRequest = await createWorkRequest({
|
|
1391
|
+
teamRunId: teamRun.id,
|
|
1392
|
+
targetMemberId: members[1].id,
|
|
1393
|
+
});
|
|
1394
|
+
const sessionManager = createSessionManagerMock();
|
|
1395
|
+
service = new TeamSchedulerService(lockService, {
|
|
1396
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1397
|
+
sessionManager,
|
|
1398
|
+
getProviderById: createProviderLookup(),
|
|
1399
|
+
});
|
|
1400
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
1401
|
+
expect(invocations).toHaveLength(2);
|
|
1402
|
+
expect(invocations.map((invocation) => invocation.workspaceId)).toEqual([workspace.id, workspace.id]);
|
|
1403
|
+
expect(new Set(invocations.map((invocation) => invocation.workRequestId))).toEqual(new Set([
|
|
1404
|
+
noneRequest.id,
|
|
1405
|
+
sharedRequest.id,
|
|
1406
|
+
]));
|
|
1407
|
+
expect(lockService.listLocks()).toEqual([
|
|
1408
|
+
{ key: `workspace:task:${teamRun.taskId}:write`, ownerId: invocations[1].id },
|
|
1409
|
+
]);
|
|
1410
|
+
});
|
|
1411
|
+
it('keeps shared command locks on the stable task key after creating a real workspace', async () => {
|
|
1412
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
1413
|
+
memberCapabilities: [commandCapabilities, commandCapabilities],
|
|
1414
|
+
withWorkspace: false,
|
|
1415
|
+
});
|
|
1416
|
+
const first = await createWorkRequest({
|
|
1417
|
+
teamRunId: teamRun.id,
|
|
1418
|
+
targetMemberId: members[0].id,
|
|
1419
|
+
});
|
|
1420
|
+
const second = await createWorkRequest({
|
|
1421
|
+
teamRunId: teamRun.id,
|
|
1422
|
+
targetMemberId: members[1].id,
|
|
1423
|
+
});
|
|
1424
|
+
service = new TeamSchedulerService(lockService, {
|
|
1425
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1426
|
+
sessionManager: createSessionManagerMock(),
|
|
1427
|
+
getProviderById: createProviderLookup(),
|
|
1428
|
+
});
|
|
1429
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
1430
|
+
expect(invocations).toHaveLength(1);
|
|
1431
|
+
expect(invocations[0]).toMatchObject({
|
|
1432
|
+
workRequestId: first.id,
|
|
1433
|
+
workspaceId: expect.any(String),
|
|
1434
|
+
status: 'RUNNING',
|
|
1435
|
+
});
|
|
1436
|
+
expect(lockService.listLocks()).toEqual([
|
|
1437
|
+
{ key: `workspace:task:${task.id}:command`, ownerId: invocations[0].id },
|
|
1438
|
+
]);
|
|
1439
|
+
await expect(prisma.workspace.count({ where: { taskId: task.id, status: 'ACTIVE' } })).resolves.toBe(1);
|
|
1440
|
+
await expect(prisma.workRequest.findUnique({ where: { id: second.id } })).resolves.toMatchObject({
|
|
1441
|
+
status: 'QUEUED',
|
|
1442
|
+
});
|
|
1443
|
+
await expect(service.startNextSessions(teamRun.id)).resolves.toEqual([]);
|
|
1444
|
+
});
|
|
1445
|
+
it('keeps shared writer locks on the stable task key after creating a real workspace', async () => {
|
|
1446
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
1447
|
+
memberCapabilities: [writeCapabilities, writeCapabilities],
|
|
1448
|
+
withWorkspace: false,
|
|
1449
|
+
});
|
|
1450
|
+
const first = await createWorkRequest({
|
|
1451
|
+
teamRunId: teamRun.id,
|
|
1452
|
+
targetMemberId: members[0].id,
|
|
1453
|
+
});
|
|
1454
|
+
const second = await createWorkRequest({
|
|
1455
|
+
teamRunId: teamRun.id,
|
|
1456
|
+
targetMemberId: members[1].id,
|
|
1457
|
+
});
|
|
1458
|
+
service = new TeamSchedulerService(lockService, {
|
|
1459
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1460
|
+
sessionManager: createSessionManagerMock(),
|
|
1461
|
+
getProviderById: createProviderLookup(),
|
|
1462
|
+
});
|
|
1463
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
1464
|
+
expect(invocations).toHaveLength(1);
|
|
1465
|
+
expect(invocations[0]).toMatchObject({
|
|
1466
|
+
workRequestId: first.id,
|
|
1467
|
+
workspaceId: expect.any(String),
|
|
1468
|
+
status: 'RUNNING',
|
|
1469
|
+
});
|
|
1470
|
+
expect(lockService.listLocks()).toEqual([
|
|
1471
|
+
{ key: `workspace:task:${task.id}:write`, ownerId: invocations[0].id },
|
|
1472
|
+
]);
|
|
1473
|
+
await expect(prisma.workspace.count({ where: { taskId: task.id, status: 'ACTIVE' } })).resolves.toBe(1);
|
|
1474
|
+
await expect(prisma.workRequest.findUnique({ where: { id: second.id } })).resolves.toMatchObject({
|
|
1475
|
+
status: 'QUEUED',
|
|
1476
|
+
});
|
|
1477
|
+
await expect(service.startNextSessions(teamRun.id)).resolves.toEqual([]);
|
|
1478
|
+
});
|
|
1479
|
+
it('deduplicates shared workspace creation across concurrent different-member session starts', async () => {
|
|
1480
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
1481
|
+
memberCapabilities: [readOnlyCapabilities, readOnlyCapabilities],
|
|
1482
|
+
withWorkspace: false,
|
|
1483
|
+
});
|
|
1484
|
+
const first = await createWorkRequest({
|
|
1485
|
+
teamRunId: teamRun.id,
|
|
1486
|
+
targetMemberId: members[0].id,
|
|
1487
|
+
});
|
|
1488
|
+
const second = await createWorkRequest({
|
|
1489
|
+
teamRunId: teamRun.id,
|
|
1490
|
+
targetMemberId: members[1].id,
|
|
1491
|
+
});
|
|
1492
|
+
const creationGate = createDeferred();
|
|
1493
|
+
let createStarted = false;
|
|
1494
|
+
const workspaceService = {
|
|
1495
|
+
create: vi.fn(async (taskId) => {
|
|
1496
|
+
createStarted = true;
|
|
1497
|
+
await creationGate.promise;
|
|
1498
|
+
return prisma.workspace.create({
|
|
1499
|
+
data: {
|
|
1500
|
+
taskId,
|
|
1501
|
+
branchName: 'team-shared-concurrent',
|
|
1502
|
+
worktreePath: path.join(testDir, 'created-workspace-concurrent'),
|
|
1503
|
+
status: 'ACTIVE',
|
|
1504
|
+
},
|
|
1505
|
+
});
|
|
1506
|
+
}),
|
|
1507
|
+
};
|
|
1508
|
+
const sessionManager = createSessionManagerMock();
|
|
1509
|
+
const firstService = new TeamSchedulerService(lockService, {
|
|
1510
|
+
workspaceService,
|
|
1511
|
+
sessionManager,
|
|
1512
|
+
getProviderById: createProviderLookup(),
|
|
1513
|
+
});
|
|
1514
|
+
const secondService = new TeamSchedulerService(lockService, {
|
|
1515
|
+
workspaceService,
|
|
1516
|
+
sessionManager,
|
|
1517
|
+
getProviderById: createProviderLookup(),
|
|
1518
|
+
});
|
|
1519
|
+
const firstStart = firstService.startNextSessions(teamRun.id);
|
|
1520
|
+
await waitForCondition(() => createStarted);
|
|
1521
|
+
const secondStart = secondService.startNextSessions(teamRun.id);
|
|
1522
|
+
creationGate.resolve();
|
|
1523
|
+
const started = (await Promise.all([firstStart, secondStart])).flat();
|
|
1524
|
+
expect(started).toHaveLength(2);
|
|
1525
|
+
expect(new Set(started.map((invocation) => invocation.workRequestId))).toEqual(new Set([first.id, second.id]));
|
|
1526
|
+
const workspaceIds = started.map((invocation) => invocation.workspaceId);
|
|
1527
|
+
expect(new Set(workspaceIds).size).toBe(1);
|
|
1528
|
+
expect(workspaceIds[0]).toEqual(expect.any(String));
|
|
1529
|
+
expect(workspaceService.create).toHaveBeenCalledTimes(1);
|
|
1530
|
+
await expect(prisma.workspace.count({ where: { taskId: task.id, status: 'ACTIVE' } })).resolves.toBe(1);
|
|
1531
|
+
const sessions = await prisma.session.findMany({
|
|
1532
|
+
where: { id: { in: started.map((invocation) => invocation.sessionId) } },
|
|
1533
|
+
orderBy: { createdAt: 'asc' },
|
|
1534
|
+
});
|
|
1535
|
+
expect(sessions).toHaveLength(2);
|
|
1536
|
+
expect(new Set(sessions.map((session) => session.workspaceId))).toEqual(new Set([workspaceIds[0]]));
|
|
1537
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
1538
|
+
});
|
|
1539
|
+
it('marks a request failed and leaves no session or lock when a provider is missing', async () => {
|
|
1540
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
1541
|
+
memberCapabilities: [writeCapabilities],
|
|
1542
|
+
withWorkspace: false,
|
|
1543
|
+
});
|
|
1544
|
+
const request = await createWorkRequest({
|
|
1545
|
+
teamRunId: teamRun.id,
|
|
1546
|
+
targetMemberId: members[0].id,
|
|
1547
|
+
});
|
|
1548
|
+
const workspaceService = createWorkspaceServiceMock();
|
|
1549
|
+
const sessionManager = createSessionManagerMock();
|
|
1550
|
+
service = new TeamSchedulerService(lockService, {
|
|
1551
|
+
workspaceService,
|
|
1552
|
+
sessionManager,
|
|
1553
|
+
getProviderById: vi.fn(() => null),
|
|
1554
|
+
});
|
|
1555
|
+
await expect(service.startNextSessions(teamRun.id)).resolves.toEqual([]);
|
|
1556
|
+
expect(workspaceService.create).not.toHaveBeenCalled();
|
|
1557
|
+
expect(sessionManager.create).not.toHaveBeenCalled();
|
|
1558
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
1559
|
+
await expect(prisma.session.count()).resolves.toBe(0);
|
|
1560
|
+
await expect(prisma.agentInvocation.findFirst({ where: { workRequestId: request.id } })).resolves.toMatchObject({
|
|
1561
|
+
memberId: members[0].id,
|
|
1562
|
+
sessionId: null,
|
|
1563
|
+
status: 'FAILED',
|
|
1564
|
+
});
|
|
1565
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
1566
|
+
status: 'STARTED',
|
|
1567
|
+
});
|
|
1568
|
+
await expect(service.startNextSessions(teamRun.id)).resolves.toEqual([]);
|
|
1569
|
+
});
|
|
1570
|
+
it('advances an idle TeamRun to review when provider missing failures consume all queued work', async () => {
|
|
1571
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
1572
|
+
memberCapabilities: [writeCapabilities],
|
|
1573
|
+
withWorkspace: false,
|
|
1574
|
+
taskStatus: TaskStatus.IN_PROGRESS,
|
|
1575
|
+
});
|
|
1576
|
+
const request = await createWorkRequest({
|
|
1577
|
+
teamRunId: teamRun.id,
|
|
1578
|
+
targetMemberId: members[0].id,
|
|
1579
|
+
});
|
|
1580
|
+
service = new TeamSchedulerService(lockService, {
|
|
1581
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1582
|
+
sessionManager: createSessionManagerMock(),
|
|
1583
|
+
getProviderById: vi.fn(() => null),
|
|
1584
|
+
});
|
|
1585
|
+
await expect(service.startNextSessions(teamRun.id)).resolves.toEqual([]);
|
|
1586
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
1587
|
+
status: 'STARTED',
|
|
1588
|
+
});
|
|
1589
|
+
await expect(prisma.agentInvocation.findFirst({ where: { workRequestId: request.id } })).resolves.toMatchObject({
|
|
1590
|
+
status: 'FAILED',
|
|
1591
|
+
sessionId: null,
|
|
1592
|
+
});
|
|
1593
|
+
await expect(prisma.task.findUnique({ where: { id: task.id } })).resolves.toMatchObject({
|
|
1594
|
+
status: TaskStatus.IN_REVIEW,
|
|
1595
|
+
});
|
|
1596
|
+
await expect(prisma.teamRun.findUnique({ where: { id: teamRun.id } })).resolves.toMatchObject({
|
|
1597
|
+
reviewReason: 'TEAM_QUIESCENT',
|
|
1598
|
+
});
|
|
1599
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
1600
|
+
});
|
|
1601
|
+
it('continues starting later queued work when an earlier request has a missing provider', async () => {
|
|
1602
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
1603
|
+
memberCapabilities: [writeCapabilities, readOnlyCapabilities],
|
|
1604
|
+
withWorkspace: false,
|
|
1605
|
+
});
|
|
1606
|
+
const missingProviderRequest = await createWorkRequest({
|
|
1607
|
+
teamRunId: teamRun.id,
|
|
1608
|
+
targetMemberId: members[0].id,
|
|
1609
|
+
});
|
|
1610
|
+
const validProviderRequest = await createWorkRequest({
|
|
1611
|
+
teamRunId: teamRun.id,
|
|
1612
|
+
targetMemberId: members[1].id,
|
|
1613
|
+
});
|
|
1614
|
+
const workspaceService = createWorkspaceServiceMock();
|
|
1615
|
+
const sessionManager = createSessionManagerMock();
|
|
1616
|
+
service = new TeamSchedulerService(lockService, {
|
|
1617
|
+
workspaceService,
|
|
1618
|
+
sessionManager,
|
|
1619
|
+
getProviderById: vi.fn((providerId) => (providerId === members[0].providerId
|
|
1620
|
+
? null
|
|
1621
|
+
: {
|
|
1622
|
+
id: providerId,
|
|
1623
|
+
name: providerId,
|
|
1624
|
+
agentType: AgentType.CODEX,
|
|
1625
|
+
env: {},
|
|
1626
|
+
config: {},
|
|
1627
|
+
isDefault: false,
|
|
1628
|
+
})),
|
|
1629
|
+
});
|
|
1630
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
1631
|
+
expect(invocations).toHaveLength(1);
|
|
1632
|
+
expect(invocations[0]).toMatchObject({
|
|
1633
|
+
workRequestId: validProviderRequest.id,
|
|
1634
|
+
memberId: members[1].id,
|
|
1635
|
+
status: 'RUNNING',
|
|
1636
|
+
sessionId: expect.any(String),
|
|
1637
|
+
});
|
|
1638
|
+
await expect(prisma.workRequest.findUnique({ where: { id: missingProviderRequest.id } })).resolves.toMatchObject({
|
|
1639
|
+
status: 'STARTED',
|
|
1640
|
+
});
|
|
1641
|
+
await expect(prisma.agentInvocation.findFirst({
|
|
1642
|
+
where: { workRequestId: missingProviderRequest.id },
|
|
1643
|
+
})).resolves.toMatchObject({
|
|
1644
|
+
memberId: members[0].id,
|
|
1645
|
+
sessionId: null,
|
|
1646
|
+
status: 'FAILED',
|
|
1647
|
+
});
|
|
1648
|
+
await expect(prisma.workRequest.findUnique({ where: { id: validProviderRequest.id } })).resolves.toMatchObject({
|
|
1649
|
+
status: 'STARTED',
|
|
1650
|
+
});
|
|
1651
|
+
expect(sessionManager.create).toHaveBeenCalledTimes(1);
|
|
1652
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
1653
|
+
});
|
|
1654
|
+
it('marks invocation and session failed and releases locks when session start fails', async () => {
|
|
1655
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
1656
|
+
memberCapabilities: [writeCapabilities],
|
|
1657
|
+
withWorkspace: false,
|
|
1658
|
+
});
|
|
1659
|
+
const request = await createWorkRequest({
|
|
1660
|
+
teamRunId: teamRun.id,
|
|
1661
|
+
targetMemberId: members[0].id,
|
|
1662
|
+
});
|
|
1663
|
+
service = new TeamSchedulerService(lockService, {
|
|
1664
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1665
|
+
sessionManager: createSessionManagerMock({ failStart: true }),
|
|
1666
|
+
getProviderById: createProviderLookup(),
|
|
1667
|
+
});
|
|
1668
|
+
await expect(service.startNextSessions(teamRun.id)).rejects.toThrow('session start failed');
|
|
1669
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
1670
|
+
const invocation = await prisma.agentInvocation.findFirstOrThrow({
|
|
1671
|
+
where: { workRequestId: request.id },
|
|
1672
|
+
});
|
|
1673
|
+
expect(invocation).toMatchObject({
|
|
1674
|
+
status: 'FAILED',
|
|
1675
|
+
workspaceId: expect.any(String),
|
|
1676
|
+
sessionId: expect.any(String),
|
|
1677
|
+
});
|
|
1678
|
+
await expect(prisma.session.findUnique({ where: { id: invocation.sessionId } })).resolves.toMatchObject({
|
|
1679
|
+
status: 'FAILED',
|
|
1680
|
+
});
|
|
1681
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
1682
|
+
status: 'STARTED',
|
|
1683
|
+
});
|
|
1684
|
+
await expect(service.startNextSessions(teamRun.id)).resolves.toEqual([]);
|
|
1685
|
+
await expect(prisma.agentInvocation.count({ where: { workRequestId: request.id } })).resolves.toBe(1);
|
|
1686
|
+
});
|
|
1687
|
+
});
|
|
1688
|
+
//# sourceMappingURL=team-scheduler.service.test.js.map
|