agent-tower 0.4.15 → 0.4.16-beta.0
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/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/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 +29 -2
- 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/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +3 -0
- package/dist/routes/index.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/team-runs.d.ts +11 -0
- package/dist/routes/team-runs.d.ts.map +1 -0
- package/dist/routes/team-runs.js +299 -0
- package/dist/routes/team-runs.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__/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 +1038 -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 +447 -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 +1158 -0
- package/dist/services/__tests__/team-scheduler.service.test.js.map +1 -0
- package/dist/services/session-manager.d.ts +31 -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/team-lock.service.d.ts +22 -0
- package/dist/services/team-lock.service.d.ts.map +1 -0
- package/dist/services/team-lock.service.js +45 -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 +89 -0
- package/dist/services/team-run.service.d.ts.map +1 -0
- package/dist/services/team-run.service.js +577 -0
- package/dist/services/team-run.service.js.map +1 -0
- package/dist/services/team-scheduler.service.d.ts +89 -0
- package/dist/services/team-scheduler.service.d.ts.map +1 -0
- package/dist/services/team-scheduler.service.js +750 -0
- package/dist/services/team-scheduler.service.js.map +1 -0
- 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-p9YI4_l4.js +1 -0
- package/dist/web/assets/{DemoPage-XwuS8vNB.js → DemoPage-B5DTSEbS.js} +3 -3
- package/dist/web/assets/{GeneralSettingsPage-CliIgpwf.js → GeneralSettingsPage-Cspr7Vol.js} +1 -1
- package/dist/web/assets/{NotificationSettingsPage-y3vhVgPv.js → NotificationSettingsPage-C9VfrRr-.js} +1 -1
- package/dist/web/assets/{ProfileSettingsPage-CkU_kZKG.js → ProfileSettingsPage-CNugU40a.js} +1 -1
- package/dist/web/assets/ProjectKanbanPage-CkGNuqxq.js +87 -0
- package/dist/web/assets/{ProjectSettingsPage-B6xhbziO.js → ProjectSettingsPage-f1dg0XMf.js} +1 -1
- package/dist/web/assets/{ProviderSettingsPage-CfvdeoEU.js → ProviderSettingsPage-D_KWkgRM.js} +1 -1
- package/dist/web/assets/TeamSettingsPage-B6WciZyi.js +1 -0
- package/dist/web/assets/{button-BWFTEdOr.js → button-B6JaSbDB.js} +1 -1
- package/dist/web/assets/{chevron-down-CuPdBAx-.js → chevron-down-CACy4UFq.js} +1 -1
- package/dist/web/assets/{chevron-right-Cs8vYTMn.js → chevron-right-DFWfnDJY.js} +1 -1
- package/dist/web/assets/chevron-up-CGlf6jzw.js +1 -0
- package/dist/web/assets/{circle-alert-EUyZcWhp.js → circle-alert-BSAUEd9O.js} +1 -1
- package/dist/web/assets/{circle-check-BXZTzqw0.js → circle-check-DMK8auwb.js} +1 -1
- package/dist/web/assets/{code-block-OCS4YCEC-BxUpvXK_.js → code-block-OCS4YCEC-Hn75KHRK.js} +1 -1
- package/dist/web/assets/{confirm-dialog-CDLHRthd.js → confirm-dialog-DHI2f7Ni.js} +1 -1
- package/dist/web/assets/{folder-picker-CUbhsnhi.js → folder-picker-CtQkbWfa.js} +1 -1
- package/dist/web/assets/index-BFAA3PTl.js +13 -0
- package/dist/web/assets/index-mBCb67dB.css +1 -0
- package/dist/web/assets/{loader-circle-BHzDVpxt.js → loader-circle-CkDnf8ST.js} +1 -1
- package/dist/web/assets/{mermaid-NOHMQCX5-BOSwJqP0.js → mermaid-NOHMQCX5-DJFgrXPd.js} +44 -44
- package/dist/web/assets/modal-B5IRN7QI.js +1 -0
- package/dist/web/assets/{pencil-BMxBxIhw.js → pencil-CJY6Ahn7.js} +1 -1
- package/dist/web/assets/{select-BUmRG0LY.js → select-BPZZlla1.js} +1 -1
- package/dist/web/assets/{use-profiles-C1vlPE-2.js → use-profiles-C2k04ICZ.js} +1 -1
- package/dist/web/assets/{use-projects-Bcd5hIOY.js → use-projects-BxuE-ulT.js} +1 -1
- package/dist/web/assets/{use-providers-Cdxr4Jbz.js → use-providers-C7fIDWzP.js} +1 -1
- 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 +153 -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 +392 -0
- package/node_modules/@prisma/client/.prisma/client/index-browser.js +381 -0
- package/node_modules/@prisma/client/.prisma/client/index.d.ts +25768 -0
- package/node_modules/@prisma/client/.prisma/client/index.js +417 -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 +280 -0
- package/node_modules/@prisma/client/.prisma/client/wasm.d.ts +1 -0
- package/node_modules/@prisma/client/.prisma/client/wasm.js +381 -0
- package/node_modules/@prisma/client/package.json +3 -2
- package/package.json +2 -1
- 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/schema.prisma +131 -1
- package/dist/web/assets/AgentDemoPage-ClnGPAV9.js +0 -1
- package/dist/web/assets/ProjectKanbanPage-BddzfZRV.js +0 -87
- package/dist/web/assets/index-BGvfX18x.css +0 -1
- package/dist/web/assets/index-CHN8jahE.js +0 -13
- package/dist/web/assets/modal-D_AU4URz.js +0 -1
|
@@ -0,0 +1,1158 @@
|
|
|
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 } 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') {
|
|
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
|
+
},
|
|
55
|
+
});
|
|
56
|
+
return { project, task };
|
|
57
|
+
}
|
|
58
|
+
async function createTeamRunFixture(options = {}) {
|
|
59
|
+
const { project, task } = await createTask();
|
|
60
|
+
const teamRun = await prisma.teamRun.create({
|
|
61
|
+
data: {
|
|
62
|
+
taskId: task.id,
|
|
63
|
+
mode: 'AUTO',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const workspace = options.withWorkspace === false
|
|
67
|
+
? null
|
|
68
|
+
: await prisma.workspace.create({
|
|
69
|
+
data: {
|
|
70
|
+
taskId: task.id,
|
|
71
|
+
branchName: `team-${teamRun.id}`,
|
|
72
|
+
worktreePath: path.join(testDir, `workspace-${teamRun.id}`),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
const capabilities = options.memberCapabilities ?? [readOnlyCapabilities];
|
|
76
|
+
const members = [];
|
|
77
|
+
for (const [index, memberCapabilities] of capabilities.entries()) {
|
|
78
|
+
members.push(await prisma.teamMember.create({
|
|
79
|
+
data: {
|
|
80
|
+
teamRunId: teamRun.id,
|
|
81
|
+
presetId: null,
|
|
82
|
+
name: `Member ${index + 1}`,
|
|
83
|
+
aliases: stringifyJson([`member-${index + 1}`]),
|
|
84
|
+
providerId: `provider-${index + 1}`,
|
|
85
|
+
rolePrompt: `Role ${index + 1}`,
|
|
86
|
+
capabilities: stringifyJson(memberCapabilities),
|
|
87
|
+
workspacePolicy: options.workspacePolicies?.[index] ?? 'shared',
|
|
88
|
+
triggerPolicy: 'MENTION_ONLY',
|
|
89
|
+
sessionPolicy: options.sessionPolicies?.[index] ?? 'new_per_request',
|
|
90
|
+
avatar: null,
|
|
91
|
+
},
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
return { project, task, teamRun, workspace, members };
|
|
95
|
+
}
|
|
96
|
+
async function createWorkRequest(options) {
|
|
97
|
+
return prisma.workRequest.create({
|
|
98
|
+
data: {
|
|
99
|
+
teamRunId: options.teamRunId,
|
|
100
|
+
requesterMemberId: null,
|
|
101
|
+
requesterType: 'user',
|
|
102
|
+
targetMemberId: options.targetMemberId,
|
|
103
|
+
triggerMessageId: `message-${Math.random().toString(16).slice(2)}`,
|
|
104
|
+
instruction: options.instruction ?? 'Please do the work',
|
|
105
|
+
ifBusy: options.ifBusy ?? 'queue',
|
|
106
|
+
cancelQueued: options.cancelQueued ?? false,
|
|
107
|
+
status: options.status ?? 'QUEUED',
|
|
108
|
+
createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, workRequestSequence++)),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
function createProviderLookup() {
|
|
113
|
+
return vi.fn((providerId) => ({
|
|
114
|
+
id: providerId,
|
|
115
|
+
name: providerId,
|
|
116
|
+
agentType: AgentType.CODEX,
|
|
117
|
+
env: {},
|
|
118
|
+
config: {},
|
|
119
|
+
isDefault: false,
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
function createWorkspaceServiceMock() {
|
|
123
|
+
return {
|
|
124
|
+
create: vi.fn(async (taskId) => {
|
|
125
|
+
const sequence = createdWorkspaceSequence++;
|
|
126
|
+
return prisma.workspace.create({
|
|
127
|
+
data: {
|
|
128
|
+
taskId,
|
|
129
|
+
branchName: `team-shared-${sequence}`,
|
|
130
|
+
worktreePath: path.join(testDir, `created-workspace-${sequence}`),
|
|
131
|
+
status: 'ACTIVE',
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function createSessionManagerMock(options = {}) {
|
|
138
|
+
return {
|
|
139
|
+
create: vi.fn(async (workspaceId, agentType, prompt, variant = 'DEFAULT', providerId) => prisma.session.create({
|
|
140
|
+
data: {
|
|
141
|
+
workspaceId,
|
|
142
|
+
agentType,
|
|
143
|
+
variant,
|
|
144
|
+
providerId: providerId ?? null,
|
|
145
|
+
prompt,
|
|
146
|
+
status: 'PENDING',
|
|
147
|
+
},
|
|
148
|
+
})),
|
|
149
|
+
start: vi.fn(async (sessionId) => {
|
|
150
|
+
if (options.failStart) {
|
|
151
|
+
throw new Error('session start failed');
|
|
152
|
+
}
|
|
153
|
+
return prisma.session.update({
|
|
154
|
+
where: { id: sessionId },
|
|
155
|
+
data: { status: 'RUNNING' },
|
|
156
|
+
});
|
|
157
|
+
}),
|
|
158
|
+
startFollowUp: vi.fn(async (sessionId) => {
|
|
159
|
+
if (options.failStart) {
|
|
160
|
+
throw new Error('session start failed');
|
|
161
|
+
}
|
|
162
|
+
return prisma.session.update({
|
|
163
|
+
where: { id: sessionId },
|
|
164
|
+
data: { status: 'RUNNING' },
|
|
165
|
+
});
|
|
166
|
+
}),
|
|
167
|
+
stop: vi.fn(async (sessionId) => prisma.session.update({
|
|
168
|
+
where: { id: sessionId },
|
|
169
|
+
data: { status: 'CANCELLED' },
|
|
170
|
+
})),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function createDeferred() {
|
|
174
|
+
let resolve;
|
|
175
|
+
let reject;
|
|
176
|
+
const promise = new Promise((promiseResolve, promiseReject) => {
|
|
177
|
+
resolve = promiseResolve;
|
|
178
|
+
reject = promiseReject;
|
|
179
|
+
});
|
|
180
|
+
return { promise, resolve, reject };
|
|
181
|
+
}
|
|
182
|
+
async function waitForCondition(predicate, timeoutMs = 1000) {
|
|
183
|
+
const startedAt = Date.now();
|
|
184
|
+
while (!predicate()) {
|
|
185
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
186
|
+
throw new Error('Timed out waiting for condition');
|
|
187
|
+
}
|
|
188
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
describe('TeamSchedulerService', () => {
|
|
192
|
+
let service;
|
|
193
|
+
let lockService;
|
|
194
|
+
beforeAll(async () => {
|
|
195
|
+
execFileSync('pnpm', ['exec', 'prisma', 'db', 'push', '--skip-generate', `--schema=${schemaPath}`], {
|
|
196
|
+
cwd: serverRoot,
|
|
197
|
+
env: { ...process.env, AGENT_TOWER_DATABASE_URL: `file:${dbPath}` },
|
|
198
|
+
stdio: 'pipe',
|
|
199
|
+
});
|
|
200
|
+
const serviceModule = await import('../team-scheduler.service.js');
|
|
201
|
+
const utilsModule = await import('../../utils/index.js');
|
|
202
|
+
TeamSchedulerService = serviceModule.TeamSchedulerService;
|
|
203
|
+
prisma = utilsModule.prisma;
|
|
204
|
+
});
|
|
205
|
+
beforeEach(async () => {
|
|
206
|
+
vi.restoreAllMocks();
|
|
207
|
+
workRequestSequence = 0;
|
|
208
|
+
createdWorkspaceSequence = 0;
|
|
209
|
+
lockService = new TeamLockService();
|
|
210
|
+
service = new TeamSchedulerService(lockService);
|
|
211
|
+
await prisma.agentInvocation.deleteMany();
|
|
212
|
+
await prisma.workRequest.deleteMany();
|
|
213
|
+
await prisma.roomMessage.deleteMany();
|
|
214
|
+
await prisma.teamMember.deleteMany();
|
|
215
|
+
await prisma.teamRun.deleteMany();
|
|
216
|
+
await prisma.teamTemplateMember.deleteMany();
|
|
217
|
+
await prisma.teamTemplate.deleteMany();
|
|
218
|
+
await prisma.memberPreset.deleteMany();
|
|
219
|
+
await prisma.session.deleteMany();
|
|
220
|
+
await prisma.workspace.deleteMany();
|
|
221
|
+
await prisma.task.deleteMany();
|
|
222
|
+
await prisma.project.deleteMany();
|
|
223
|
+
});
|
|
224
|
+
afterAll(async () => {
|
|
225
|
+
vi.restoreAllMocks();
|
|
226
|
+
await prisma.$disconnect();
|
|
227
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
228
|
+
});
|
|
229
|
+
it('approves a pending WorkRequest into the queue', async () => {
|
|
230
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
231
|
+
const request = await createWorkRequest({
|
|
232
|
+
teamRunId: teamRun.id,
|
|
233
|
+
targetMemberId: members[0].id,
|
|
234
|
+
status: 'PENDING_APPROVAL',
|
|
235
|
+
});
|
|
236
|
+
const approved = await service.approveWorkRequest(request.id);
|
|
237
|
+
expect(approved.status).toBe('QUEUED');
|
|
238
|
+
});
|
|
239
|
+
it('approves a pending WorkRequest and immediately starts eligible queued work', async () => {
|
|
240
|
+
const { teamRun, members } = await createTeamRunFixture({ withWorkspace: false });
|
|
241
|
+
const request = await createWorkRequest({
|
|
242
|
+
teamRunId: teamRun.id,
|
|
243
|
+
targetMemberId: members[0].id,
|
|
244
|
+
status: 'PENDING_APPROVAL',
|
|
245
|
+
});
|
|
246
|
+
service = new TeamSchedulerService(lockService, {
|
|
247
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
248
|
+
sessionManager: createSessionManagerMock(),
|
|
249
|
+
getProviderById: createProviderLookup(),
|
|
250
|
+
});
|
|
251
|
+
const result = await service.approveWorkRequestAndStartNext(request.id);
|
|
252
|
+
expect(result.workRequest).toMatchObject({
|
|
253
|
+
id: request.id,
|
|
254
|
+
status: 'QUEUED',
|
|
255
|
+
});
|
|
256
|
+
expect(result.startedInvocations).toHaveLength(1);
|
|
257
|
+
expect(result.startedInvocations[0]).toMatchObject({
|
|
258
|
+
teamRunId: teamRun.id,
|
|
259
|
+
workRequestId: request.id,
|
|
260
|
+
memberId: members[0].id,
|
|
261
|
+
status: 'RUNNING',
|
|
262
|
+
sessionId: expect.any(String),
|
|
263
|
+
});
|
|
264
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
265
|
+
status: 'STARTED',
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
it('returns a clear error when approving a non-pending WorkRequest', async () => {
|
|
269
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
270
|
+
const request = await createWorkRequest({
|
|
271
|
+
teamRunId: teamRun.id,
|
|
272
|
+
targetMemberId: members[0].id,
|
|
273
|
+
status: 'QUEUED',
|
|
274
|
+
});
|
|
275
|
+
await expect(service.approveWorkRequest(request.id)).rejects.toMatchObject({
|
|
276
|
+
code: 'INVALID_STATE_TRANSITION',
|
|
277
|
+
statusCode: 400,
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
it('rejects a pending WorkRequest', async () => {
|
|
281
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
282
|
+
const request = await createWorkRequest({
|
|
283
|
+
teamRunId: teamRun.id,
|
|
284
|
+
targetMemberId: members[0].id,
|
|
285
|
+
status: 'PENDING_APPROVAL',
|
|
286
|
+
});
|
|
287
|
+
const rejected = await service.rejectWorkRequest(request.id);
|
|
288
|
+
expect(rejected.status).toBe('REJECTED');
|
|
289
|
+
});
|
|
290
|
+
it('returns a clear error when rejecting a non-pending WorkRequest', async () => {
|
|
291
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
292
|
+
const request = await createWorkRequest({
|
|
293
|
+
teamRunId: teamRun.id,
|
|
294
|
+
targetMemberId: members[0].id,
|
|
295
|
+
status: 'QUEUED',
|
|
296
|
+
});
|
|
297
|
+
await expect(service.rejectWorkRequest(request.id)).rejects.toMatchObject({
|
|
298
|
+
code: 'INVALID_STATE_TRANSITION',
|
|
299
|
+
statusCode: 400,
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
it('cancels pending and queued WorkRequests', async () => {
|
|
303
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
304
|
+
const pending = await createWorkRequest({
|
|
305
|
+
teamRunId: teamRun.id,
|
|
306
|
+
targetMemberId: members[0].id,
|
|
307
|
+
status: 'PENDING_APPROVAL',
|
|
308
|
+
});
|
|
309
|
+
const queued = await createWorkRequest({
|
|
310
|
+
teamRunId: teamRun.id,
|
|
311
|
+
targetMemberId: members[0].id,
|
|
312
|
+
status: 'QUEUED',
|
|
313
|
+
});
|
|
314
|
+
await expect(service.cancelWorkRequest(pending.id)).resolves.toMatchObject({ status: 'CANCELLED' });
|
|
315
|
+
await expect(service.cancelWorkRequest(queued.id)).resolves.toMatchObject({ status: 'CANCELLED' });
|
|
316
|
+
});
|
|
317
|
+
it('does not cancel a started WorkRequest', async () => {
|
|
318
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
319
|
+
const request = await createWorkRequest({
|
|
320
|
+
teamRunId: teamRun.id,
|
|
321
|
+
targetMemberId: members[0].id,
|
|
322
|
+
status: 'STARTED',
|
|
323
|
+
});
|
|
324
|
+
await expect(service.cancelWorkRequest(request.id)).rejects.toMatchObject({
|
|
325
|
+
code: 'INVALID_STATE_TRANSITION',
|
|
326
|
+
statusCode: 400,
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
it('does not let a stale cancel overwrite a WorkRequest that was started before the conditional write', async () => {
|
|
330
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
331
|
+
const request = await createWorkRequest({
|
|
332
|
+
teamRunId: teamRun.id,
|
|
333
|
+
targetMemberId: members[0].id,
|
|
334
|
+
status: 'QUEUED',
|
|
335
|
+
});
|
|
336
|
+
const originalTransaction = prisma.$transaction.bind(prisma);
|
|
337
|
+
const transactionSpy = vi.spyOn(prisma, '$transaction');
|
|
338
|
+
transactionSpy.mockImplementationOnce(async (arg, ...rest) => {
|
|
339
|
+
await prisma.workRequest.update({
|
|
340
|
+
where: { id: request.id },
|
|
341
|
+
data: { status: 'STARTED' },
|
|
342
|
+
});
|
|
343
|
+
return originalTransaction(arg, ...rest);
|
|
344
|
+
});
|
|
345
|
+
await expect(service.cancelWorkRequest(request.id)).rejects.toMatchObject({
|
|
346
|
+
code: 'INVALID_STATE_TRANSITION',
|
|
347
|
+
statusCode: 400,
|
|
348
|
+
message: expect.stringContaining('STARTED'),
|
|
349
|
+
});
|
|
350
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
351
|
+
status: 'STARTED',
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
it('starts queued work by creating a queued AgentInvocation without workspace or session creation', async () => {
|
|
355
|
+
const { teamRun, members } = await createTeamRunFixture({ withWorkspace: false });
|
|
356
|
+
const request = await createWorkRequest({
|
|
357
|
+
teamRunId: teamRun.id,
|
|
358
|
+
targetMemberId: members[0].id,
|
|
359
|
+
status: 'QUEUED',
|
|
360
|
+
});
|
|
361
|
+
const workspaceCountBefore = await prisma.workspace.count();
|
|
362
|
+
const sessionCountBefore = await prisma.session.count();
|
|
363
|
+
const invocations = await service.startNext(teamRun.id);
|
|
364
|
+
expect(invocations).toHaveLength(1);
|
|
365
|
+
expect(invocations[0]).toMatchObject({
|
|
366
|
+
teamRunId: teamRun.id,
|
|
367
|
+
workRequestId: request.id,
|
|
368
|
+
memberId: members[0].id,
|
|
369
|
+
workspaceId: null,
|
|
370
|
+
sessionId: null,
|
|
371
|
+
status: 'QUEUED',
|
|
372
|
+
});
|
|
373
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
374
|
+
status: 'STARTED',
|
|
375
|
+
});
|
|
376
|
+
await expect(prisma.workspace.count()).resolves.toBe(workspaceCountBefore);
|
|
377
|
+
await expect(prisma.session.count()).resolves.toBe(sessionCountBefore);
|
|
378
|
+
});
|
|
379
|
+
it('does not start new work for a member with an active invocation', async () => {
|
|
380
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
381
|
+
await prisma.agentInvocation.create({
|
|
382
|
+
data: {
|
|
383
|
+
teamRunId: teamRun.id,
|
|
384
|
+
workRequestId: 'existing-work-request',
|
|
385
|
+
memberId: members[0].id,
|
|
386
|
+
status: 'RUNNING',
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
const request = await createWorkRequest({
|
|
390
|
+
teamRunId: teamRun.id,
|
|
391
|
+
targetMemberId: members[0].id,
|
|
392
|
+
});
|
|
393
|
+
await expect(service.startNext(teamRun.id)).resolves.toEqual([]);
|
|
394
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
395
|
+
status: 'QUEUED',
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
it('starts only one queued WorkRequest for the same member in a single batch', async () => {
|
|
399
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
400
|
+
const first = await createWorkRequest({
|
|
401
|
+
teamRunId: teamRun.id,
|
|
402
|
+
targetMemberId: members[0].id,
|
|
403
|
+
instruction: 'First',
|
|
404
|
+
});
|
|
405
|
+
const second = await createWorkRequest({
|
|
406
|
+
teamRunId: teamRun.id,
|
|
407
|
+
targetMemberId: members[0].id,
|
|
408
|
+
instruction: 'Second',
|
|
409
|
+
});
|
|
410
|
+
const invocations = await service.startNext(teamRun.id);
|
|
411
|
+
expect(invocations).toHaveLength(1);
|
|
412
|
+
expect(invocations[0].workRequestId).toBe(first.id);
|
|
413
|
+
await expect(prisma.workRequest.findUnique({ where: { id: first.id } })).resolves.toMatchObject({
|
|
414
|
+
status: 'STARTED',
|
|
415
|
+
});
|
|
416
|
+
await expect(prisma.workRequest.findUnique({ where: { id: second.id } })).resolves.toMatchObject({
|
|
417
|
+
status: 'QUEUED',
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
it('does not double-start a read-only member during concurrent startNext calls', async () => {
|
|
421
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
422
|
+
const first = await createWorkRequest({
|
|
423
|
+
teamRunId: teamRun.id,
|
|
424
|
+
targetMemberId: members[0].id,
|
|
425
|
+
instruction: 'First',
|
|
426
|
+
});
|
|
427
|
+
const second = await createWorkRequest({
|
|
428
|
+
teamRunId: teamRun.id,
|
|
429
|
+
targetMemberId: members[0].id,
|
|
430
|
+
instruction: 'Second',
|
|
431
|
+
});
|
|
432
|
+
const anotherService = new TeamSchedulerService(lockService);
|
|
433
|
+
await Promise.all([
|
|
434
|
+
service.startNext(teamRun.id),
|
|
435
|
+
anotherService.startNext(teamRun.id),
|
|
436
|
+
]);
|
|
437
|
+
await expect(prisma.agentInvocation.count({
|
|
438
|
+
where: {
|
|
439
|
+
teamRunId: teamRun.id,
|
|
440
|
+
memberId: members[0].id,
|
|
441
|
+
status: { in: ['QUEUED', 'RUNNING', 'SESSION_ENDED', 'WAITING_ROOM_REPLY'] },
|
|
442
|
+
},
|
|
443
|
+
})).resolves.toBe(1);
|
|
444
|
+
const reloaded = await prisma.workRequest.findMany({
|
|
445
|
+
where: { id: { in: [first.id, second.id] } },
|
|
446
|
+
orderBy: { createdAt: 'asc' },
|
|
447
|
+
});
|
|
448
|
+
expect(reloaded.map((request) => request.status).sort()).toEqual(['QUEUED', 'STARTED']);
|
|
449
|
+
});
|
|
450
|
+
it('starts only one member when two members need the shared workspace write lock', async () => {
|
|
451
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
452
|
+
memberCapabilities: [writeCapabilities, writeCapabilities],
|
|
453
|
+
});
|
|
454
|
+
const first = await createWorkRequest({
|
|
455
|
+
teamRunId: teamRun.id,
|
|
456
|
+
targetMemberId: members[0].id,
|
|
457
|
+
});
|
|
458
|
+
const second = await createWorkRequest({
|
|
459
|
+
teamRunId: teamRun.id,
|
|
460
|
+
targetMemberId: members[1].id,
|
|
461
|
+
});
|
|
462
|
+
const invocations = await service.startNext(teamRun.id);
|
|
463
|
+
expect(invocations).toHaveLength(1);
|
|
464
|
+
expect(invocations[0].workRequestId).toBe(first.id);
|
|
465
|
+
await expect(prisma.workRequest.findUnique({ where: { id: first.id } })).resolves.toMatchObject({
|
|
466
|
+
status: 'STARTED',
|
|
467
|
+
});
|
|
468
|
+
await expect(prisma.workRequest.findUnique({ where: { id: second.id } })).resolves.toMatchObject({
|
|
469
|
+
status: 'QUEUED',
|
|
470
|
+
});
|
|
471
|
+
expect(lockService.listLocks()).toEqual([
|
|
472
|
+
{ key: `workspace:task:${task.id}:write`, ownerId: invocations[0].id },
|
|
473
|
+
]);
|
|
474
|
+
});
|
|
475
|
+
it('uses a task proxy lock for shared write work when no active workspace exists', async () => {
|
|
476
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
477
|
+
memberCapabilities: [writeCapabilities, writeCapabilities],
|
|
478
|
+
withWorkspace: false,
|
|
479
|
+
});
|
|
480
|
+
const first = await createWorkRequest({
|
|
481
|
+
teamRunId: teamRun.id,
|
|
482
|
+
targetMemberId: members[0].id,
|
|
483
|
+
});
|
|
484
|
+
const second = await createWorkRequest({
|
|
485
|
+
teamRunId: teamRun.id,
|
|
486
|
+
targetMemberId: members[1].id,
|
|
487
|
+
});
|
|
488
|
+
const workspaceCountBefore = await prisma.workspace.count();
|
|
489
|
+
const sessionCountBefore = await prisma.session.count();
|
|
490
|
+
const invocations = await service.startNext(teamRun.id);
|
|
491
|
+
expect(invocations).toHaveLength(1);
|
|
492
|
+
expect(invocations[0]).toMatchObject({
|
|
493
|
+
workRequestId: first.id,
|
|
494
|
+
workspaceId: null,
|
|
495
|
+
sessionId: null,
|
|
496
|
+
status: 'QUEUED',
|
|
497
|
+
});
|
|
498
|
+
expect(lockService.listLocks()).toEqual([
|
|
499
|
+
{ key: `workspace:task:${task.id}:write`, ownerId: invocations[0].id },
|
|
500
|
+
]);
|
|
501
|
+
await expect(prisma.workRequest.findUnique({ where: { id: first.id } })).resolves.toMatchObject({
|
|
502
|
+
status: 'STARTED',
|
|
503
|
+
});
|
|
504
|
+
await expect(prisma.workRequest.findUnique({ where: { id: second.id } })).resolves.toMatchObject({
|
|
505
|
+
status: 'QUEUED',
|
|
506
|
+
});
|
|
507
|
+
await expect(prisma.workspace.count()).resolves.toBe(workspaceCountBefore);
|
|
508
|
+
await expect(prisma.session.count()).resolves.toBe(sessionCountBefore);
|
|
509
|
+
});
|
|
510
|
+
it('uses a task proxy lock for shared command work when no active workspace exists', async () => {
|
|
511
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
512
|
+
memberCapabilities: [commandCapabilities, commandCapabilities],
|
|
513
|
+
withWorkspace: false,
|
|
514
|
+
});
|
|
515
|
+
const first = await createWorkRequest({
|
|
516
|
+
teamRunId: teamRun.id,
|
|
517
|
+
targetMemberId: members[0].id,
|
|
518
|
+
});
|
|
519
|
+
const second = await createWorkRequest({
|
|
520
|
+
teamRunId: teamRun.id,
|
|
521
|
+
targetMemberId: members[1].id,
|
|
522
|
+
});
|
|
523
|
+
const workspaceCountBefore = await prisma.workspace.count();
|
|
524
|
+
const sessionCountBefore = await prisma.session.count();
|
|
525
|
+
const invocations = await service.startNext(teamRun.id);
|
|
526
|
+
expect(invocations).toHaveLength(1);
|
|
527
|
+
expect(invocations[0]).toMatchObject({
|
|
528
|
+
workRequestId: first.id,
|
|
529
|
+
workspaceId: null,
|
|
530
|
+
sessionId: null,
|
|
531
|
+
status: 'QUEUED',
|
|
532
|
+
});
|
|
533
|
+
expect(lockService.listLocks()).toEqual([
|
|
534
|
+
{ key: `workspace:task:${task.id}:command`, ownerId: invocations[0].id },
|
|
535
|
+
]);
|
|
536
|
+
await expect(prisma.workRequest.findUnique({ where: { id: first.id } })).resolves.toMatchObject({
|
|
537
|
+
status: 'STARTED',
|
|
538
|
+
});
|
|
539
|
+
await expect(prisma.workRequest.findUnique({ where: { id: second.id } })).resolves.toMatchObject({
|
|
540
|
+
status: 'QUEUED',
|
|
541
|
+
});
|
|
542
|
+
await expect(prisma.workspace.count()).resolves.toBe(workspaceCountBefore);
|
|
543
|
+
await expect(prisma.session.count()).resolves.toBe(sessionCountBefore);
|
|
544
|
+
});
|
|
545
|
+
it('starts read-only work for different members in parallel', async () => {
|
|
546
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
547
|
+
memberCapabilities: [readOnlyCapabilities, readOnlyCapabilities],
|
|
548
|
+
});
|
|
549
|
+
await createWorkRequest({
|
|
550
|
+
teamRunId: teamRun.id,
|
|
551
|
+
targetMemberId: members[0].id,
|
|
552
|
+
});
|
|
553
|
+
await createWorkRequest({
|
|
554
|
+
teamRunId: teamRun.id,
|
|
555
|
+
targetMemberId: members[1].id,
|
|
556
|
+
});
|
|
557
|
+
const invocations = await service.startNext(teamRun.id);
|
|
558
|
+
expect(invocations).toHaveLength(2);
|
|
559
|
+
await expect(prisma.workRequest.count({
|
|
560
|
+
where: { teamRunId: teamRun.id, status: 'STARTED' },
|
|
561
|
+
})).resolves.toBe(2);
|
|
562
|
+
});
|
|
563
|
+
it('leaves work queued when an external owner holds the required lock', async () => {
|
|
564
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
565
|
+
memberCapabilities: [writeCapabilities],
|
|
566
|
+
});
|
|
567
|
+
const request = await createWorkRequest({
|
|
568
|
+
teamRunId: teamRun.id,
|
|
569
|
+
targetMemberId: members[0].id,
|
|
570
|
+
});
|
|
571
|
+
expect(lockService.acquire('external-owner', [`workspace:task:${task.id}:write`])).toBe(true);
|
|
572
|
+
await expect(service.startNext(teamRun.id)).resolves.toEqual([]);
|
|
573
|
+
await expect(prisma.agentInvocation.count()).resolves.toBe(0);
|
|
574
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
575
|
+
status: 'QUEUED',
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
it('releases acquired locks when invocation creation fails', async () => {
|
|
579
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
580
|
+
memberCapabilities: [writeCapabilities],
|
|
581
|
+
});
|
|
582
|
+
const request = await createWorkRequest({
|
|
583
|
+
teamRunId: teamRun.id,
|
|
584
|
+
targetMemberId: members[0].id,
|
|
585
|
+
});
|
|
586
|
+
const transactionSpy = vi.spyOn(prisma, '$transaction').mockRejectedValueOnce(new Error('transaction failed'));
|
|
587
|
+
await expect(service.startNext(teamRun.id)).rejects.toThrow('transaction failed');
|
|
588
|
+
transactionSpy.mockRestore();
|
|
589
|
+
expect(lockService.listLocks().filter((lock) => lock.ownerId.startsWith('pending:'))).toEqual([]);
|
|
590
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
591
|
+
await expect(prisma.agentInvocation.count()).resolves.toBe(0);
|
|
592
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
593
|
+
status: 'QUEUED',
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
it('marks cancel_current_and_start plans as requiring a future stop integration', async () => {
|
|
597
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
598
|
+
await prisma.agentInvocation.create({
|
|
599
|
+
data: {
|
|
600
|
+
teamRunId: teamRun.id,
|
|
601
|
+
workRequestId: 'existing-work-request',
|
|
602
|
+
memberId: members[0].id,
|
|
603
|
+
status: 'RUNNING',
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
const request = await createWorkRequest({
|
|
607
|
+
teamRunId: teamRun.id,
|
|
608
|
+
targetMemberId: members[0].id,
|
|
609
|
+
ifBusy: 'cancel_current_and_start',
|
|
610
|
+
});
|
|
611
|
+
await expect(service.planNext(teamRun.id)).resolves.toEqual([
|
|
612
|
+
expect.objectContaining({
|
|
613
|
+
workRequestId: request.id,
|
|
614
|
+
canStart: false,
|
|
615
|
+
blockedReason: 'member_busy',
|
|
616
|
+
requiresStopCurrent: true,
|
|
617
|
+
}),
|
|
618
|
+
]);
|
|
619
|
+
});
|
|
620
|
+
it('leaves dedicated workspace members blocked because dedicated startup is reserved', async () => {
|
|
621
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
622
|
+
workspacePolicies: ['dedicated'],
|
|
623
|
+
});
|
|
624
|
+
const request = await createWorkRequest({
|
|
625
|
+
teamRunId: teamRun.id,
|
|
626
|
+
targetMemberId: members[0].id,
|
|
627
|
+
});
|
|
628
|
+
await expect(service.planNext(teamRun.id)).resolves.toEqual([
|
|
629
|
+
expect.objectContaining({
|
|
630
|
+
workRequestId: request.id,
|
|
631
|
+
canStart: false,
|
|
632
|
+
blockedReason: 'unsupported_workspace_policy',
|
|
633
|
+
}),
|
|
634
|
+
]);
|
|
635
|
+
await expect(service.startNext(teamRun.id)).resolves.toEqual([]);
|
|
636
|
+
await expect(prisma.agentInvocation.count()).resolves.toBe(0);
|
|
637
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
638
|
+
status: 'QUEUED',
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
it('cancels only queued requests for the same member when cancelQueued is set', async () => {
|
|
642
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
643
|
+
const first = await createWorkRequest({
|
|
644
|
+
teamRunId: teamRun.id,
|
|
645
|
+
targetMemberId: members[0].id,
|
|
646
|
+
cancelQueued: true,
|
|
647
|
+
});
|
|
648
|
+
const second = await createWorkRequest({
|
|
649
|
+
teamRunId: teamRun.id,
|
|
650
|
+
targetMemberId: members[0].id,
|
|
651
|
+
});
|
|
652
|
+
const started = await createWorkRequest({
|
|
653
|
+
teamRunId: teamRun.id,
|
|
654
|
+
targetMemberId: members[0].id,
|
|
655
|
+
status: 'STARTED',
|
|
656
|
+
});
|
|
657
|
+
await service.startNext(teamRun.id);
|
|
658
|
+
await expect(prisma.workRequest.findUnique({ where: { id: first.id } })).resolves.toMatchObject({
|
|
659
|
+
status: 'STARTED',
|
|
660
|
+
});
|
|
661
|
+
await expect(prisma.workRequest.findUnique({ where: { id: second.id } })).resolves.toMatchObject({
|
|
662
|
+
status: 'CANCELLED',
|
|
663
|
+
});
|
|
664
|
+
await expect(prisma.workRequest.findUnique({ where: { id: started.id } })).resolves.toMatchObject({
|
|
665
|
+
status: 'STARTED',
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
it('starts a shared member by creating the task shared workspace, session, and running invocation', async () => {
|
|
669
|
+
const { task, teamRun, members } = await createTeamRunFixture({ withWorkspace: false });
|
|
670
|
+
const request = await createWorkRequest({
|
|
671
|
+
teamRunId: teamRun.id,
|
|
672
|
+
targetMemberId: members[0].id,
|
|
673
|
+
instruction: 'Implement the shared work',
|
|
674
|
+
});
|
|
675
|
+
const workspaceService = createWorkspaceServiceMock();
|
|
676
|
+
const sessionManager = createSessionManagerMock();
|
|
677
|
+
service = new TeamSchedulerService(lockService, {
|
|
678
|
+
workspaceService,
|
|
679
|
+
sessionManager,
|
|
680
|
+
getProviderById: createProviderLookup(),
|
|
681
|
+
});
|
|
682
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
683
|
+
expect(workspaceService.create).toHaveBeenCalledWith(task.id);
|
|
684
|
+
expect(sessionManager.create).toHaveBeenCalledWith(invocations[0].workspaceId, AgentType.CODEX, 'Role 1\n\nTask:\nImplement the shared work', 'DEFAULT', members[0].providerId);
|
|
685
|
+
expect(sessionManager.start).toHaveBeenCalledWith(invocations[0].sessionId);
|
|
686
|
+
expect(sessionManager.startFollowUp).not.toHaveBeenCalled();
|
|
687
|
+
expect(invocations).toHaveLength(1);
|
|
688
|
+
expect(invocations[0]).toMatchObject({
|
|
689
|
+
teamRunId: teamRun.id,
|
|
690
|
+
workRequestId: request.id,
|
|
691
|
+
memberId: members[0].id,
|
|
692
|
+
workspaceId: expect.any(String),
|
|
693
|
+
sessionId: expect.any(String),
|
|
694
|
+
status: 'RUNNING',
|
|
695
|
+
});
|
|
696
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
697
|
+
status: 'STARTED',
|
|
698
|
+
});
|
|
699
|
+
await expect(prisma.session.findUnique({ where: { id: invocations[0].sessionId } })).resolves.toMatchObject({
|
|
700
|
+
workspaceId: invocations[0].workspaceId,
|
|
701
|
+
providerId: members[0].providerId,
|
|
702
|
+
status: 'RUNNING',
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
it('starts resume_last members with executor resume context while keeping a new Tower session and invocation', async () => {
|
|
706
|
+
const { workspace, teamRun, members } = await createTeamRunFixture({
|
|
707
|
+
sessionPolicies: ['resume_last'],
|
|
708
|
+
});
|
|
709
|
+
const previousRequest = await createWorkRequest({
|
|
710
|
+
teamRunId: teamRun.id,
|
|
711
|
+
targetMemberId: members[0].id,
|
|
712
|
+
status: 'STARTED',
|
|
713
|
+
instruction: 'Previous work',
|
|
714
|
+
});
|
|
715
|
+
const previousSession = await prisma.session.create({
|
|
716
|
+
data: {
|
|
717
|
+
workspaceId: workspace.id,
|
|
718
|
+
agentType: AgentType.CODEX,
|
|
719
|
+
providerId: members[0].providerId,
|
|
720
|
+
prompt: 'previous prompt',
|
|
721
|
+
status: 'COMPLETED',
|
|
722
|
+
logSnapshot: JSON.stringify({ sessionId: 'agent-native-session-1', entries: [] }),
|
|
723
|
+
createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
724
|
+
updatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
await prisma.agentInvocation.create({
|
|
728
|
+
data: {
|
|
729
|
+
teamRunId: teamRun.id,
|
|
730
|
+
workRequestId: previousRequest.id,
|
|
731
|
+
memberId: members[0].id,
|
|
732
|
+
workspaceId: workspace.id,
|
|
733
|
+
sessionId: previousSession.id,
|
|
734
|
+
status: 'COMPLETED',
|
|
735
|
+
createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
736
|
+
updatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
const nextRequest = await createWorkRequest({
|
|
740
|
+
teamRunId: teamRun.id,
|
|
741
|
+
targetMemberId: members[0].id,
|
|
742
|
+
instruction: 'Continue with context',
|
|
743
|
+
});
|
|
744
|
+
const sessionManager = createSessionManagerMock();
|
|
745
|
+
service = new TeamSchedulerService(lockService, {
|
|
746
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
747
|
+
sessionManager,
|
|
748
|
+
getProviderById: createProviderLookup(),
|
|
749
|
+
});
|
|
750
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
751
|
+
expect(invocations).toHaveLength(1);
|
|
752
|
+
expect(invocations[0]).toMatchObject({
|
|
753
|
+
workRequestId: nextRequest.id,
|
|
754
|
+
memberId: members[0].id,
|
|
755
|
+
sessionId: expect.any(String),
|
|
756
|
+
status: 'RUNNING',
|
|
757
|
+
});
|
|
758
|
+
expect(invocations[0].sessionId).not.toBe(previousSession.id);
|
|
759
|
+
expect(sessionManager.create).toHaveBeenCalledWith(workspace.id, AgentType.CODEX, 'Role 1\n\nTask:\nContinue with context', 'DEFAULT', members[0].providerId);
|
|
760
|
+
expect(sessionManager.startFollowUp).toHaveBeenCalledWith(invocations[0].sessionId, previousSession.id);
|
|
761
|
+
expect(sessionManager.start).not.toHaveBeenCalled();
|
|
762
|
+
await expect(prisma.session.findUnique({ where: { id: invocations[0].sessionId } })).resolves.toMatchObject({
|
|
763
|
+
workspaceId: workspace.id,
|
|
764
|
+
providerId: members[0].providerId,
|
|
765
|
+
prompt: 'Role 1\n\nTask:\nContinue with context',
|
|
766
|
+
status: 'RUNNING',
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
it('falls back to a normal session start for resume_last members without previous native context', async () => {
|
|
770
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
771
|
+
sessionPolicies: ['resume_last'],
|
|
772
|
+
});
|
|
773
|
+
await createWorkRequest({
|
|
774
|
+
teamRunId: teamRun.id,
|
|
775
|
+
targetMemberId: members[0].id,
|
|
776
|
+
instruction: 'Fresh work',
|
|
777
|
+
});
|
|
778
|
+
const sessionManager = createSessionManagerMock();
|
|
779
|
+
service = new TeamSchedulerService(lockService, {
|
|
780
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
781
|
+
sessionManager,
|
|
782
|
+
getProviderById: createProviderLookup(),
|
|
783
|
+
});
|
|
784
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
785
|
+
expect(invocations).toHaveLength(1);
|
|
786
|
+
expect(sessionManager.start).toHaveBeenCalledWith(invocations[0].sessionId);
|
|
787
|
+
expect(sessionManager.startFollowUp).not.toHaveBeenCalled();
|
|
788
|
+
});
|
|
789
|
+
it('stops member work by cancelling no-session active work, queued requests, and releasing locks', async () => {
|
|
790
|
+
const { task, workspace, teamRun, members } = await createTeamRunFixture({
|
|
791
|
+
memberCapabilities: [writeCapabilities, writeCapabilities],
|
|
792
|
+
});
|
|
793
|
+
const activeRequest = await createWorkRequest({
|
|
794
|
+
teamRunId: teamRun.id,
|
|
795
|
+
targetMemberId: members[0].id,
|
|
796
|
+
status: 'STARTED',
|
|
797
|
+
});
|
|
798
|
+
const activeInvocation = await prisma.agentInvocation.create({
|
|
799
|
+
data: {
|
|
800
|
+
teamRunId: teamRun.id,
|
|
801
|
+
workRequestId: activeRequest.id,
|
|
802
|
+
memberId: members[0].id,
|
|
803
|
+
workspaceId: workspace.id,
|
|
804
|
+
sessionId: null,
|
|
805
|
+
status: 'QUEUED',
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
expect(lockService.acquire(activeInvocation.id, [`workspace:task:${task.id}:write`])).toBe(true);
|
|
809
|
+
const pending = await createWorkRequest({
|
|
810
|
+
teamRunId: teamRun.id,
|
|
811
|
+
targetMemberId: members[0].id,
|
|
812
|
+
status: 'PENDING_APPROVAL',
|
|
813
|
+
});
|
|
814
|
+
const queued = await createWorkRequest({
|
|
815
|
+
teamRunId: teamRun.id,
|
|
816
|
+
targetMemberId: members[0].id,
|
|
817
|
+
status: 'QUEUED',
|
|
818
|
+
});
|
|
819
|
+
const otherMemberQueued = await createWorkRequest({
|
|
820
|
+
teamRunId: teamRun.id,
|
|
821
|
+
targetMemberId: members[1].id,
|
|
822
|
+
status: 'QUEUED',
|
|
823
|
+
});
|
|
824
|
+
service = new TeamSchedulerService(lockService, {
|
|
825
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
826
|
+
sessionManager: createSessionManagerMock(),
|
|
827
|
+
getProviderById: createProviderLookup(),
|
|
828
|
+
});
|
|
829
|
+
const result = await service.stopMemberWork(teamRun.id, members[0].id, { cancelQueued: true });
|
|
830
|
+
expect(result.stoppedSessionIds).toEqual([]);
|
|
831
|
+
expect(result.cancelledInvocationIds).toEqual([activeInvocation.id]);
|
|
832
|
+
expect(new Set(result.cancelledWorkRequestIds)).toEqual(new Set([
|
|
833
|
+
activeRequest.id,
|
|
834
|
+
pending.id,
|
|
835
|
+
queued.id,
|
|
836
|
+
]));
|
|
837
|
+
expect(result.startedInvocations).toHaveLength(1);
|
|
838
|
+
expect(result.startedInvocations[0]).toMatchObject({
|
|
839
|
+
workRequestId: otherMemberQueued.id,
|
|
840
|
+
memberId: members[1].id,
|
|
841
|
+
status: 'RUNNING',
|
|
842
|
+
});
|
|
843
|
+
expect(lockService.listLocks()).toEqual([
|
|
844
|
+
{ key: `workspace:task:${task.id}:write`, ownerId: result.startedInvocations[0].id },
|
|
845
|
+
]);
|
|
846
|
+
await expect(prisma.agentInvocation.findUnique({ where: { id: activeInvocation.id } })).resolves.toMatchObject({
|
|
847
|
+
status: 'CANCELLED',
|
|
848
|
+
nextRoomReplyReminderAt: null,
|
|
849
|
+
});
|
|
850
|
+
const reloadedRequests = await prisma.workRequest.findMany({
|
|
851
|
+
where: { id: { in: [activeRequest.id, pending.id, queued.id, otherMemberQueued.id] } },
|
|
852
|
+
orderBy: { createdAt: 'asc' },
|
|
853
|
+
});
|
|
854
|
+
expect(reloadedRequests.map((request) => [request.id, request.status])).toEqual([
|
|
855
|
+
[activeRequest.id, 'CANCELLED'],
|
|
856
|
+
[pending.id, 'CANCELLED'],
|
|
857
|
+
[queued.id, 'CANCELLED'],
|
|
858
|
+
[otherMemberQueued.id, 'STARTED'],
|
|
859
|
+
]);
|
|
860
|
+
});
|
|
861
|
+
it('stops session-backed member work through SessionManager.stop and then starts queued work', async () => {
|
|
862
|
+
const { task, workspace, teamRun, members } = await createTeamRunFixture({
|
|
863
|
+
memberCapabilities: [writeCapabilities, writeCapabilities],
|
|
864
|
+
});
|
|
865
|
+
const activeRequest = await createWorkRequest({
|
|
866
|
+
teamRunId: teamRun.id,
|
|
867
|
+
targetMemberId: members[0].id,
|
|
868
|
+
status: 'STARTED',
|
|
869
|
+
});
|
|
870
|
+
const session = await prisma.session.create({
|
|
871
|
+
data: {
|
|
872
|
+
workspaceId: workspace.id,
|
|
873
|
+
agentType: AgentType.CODEX,
|
|
874
|
+
providerId: members[0].providerId,
|
|
875
|
+
prompt: 'Do active work',
|
|
876
|
+
status: 'RUNNING',
|
|
877
|
+
},
|
|
878
|
+
});
|
|
879
|
+
const activeInvocation = await prisma.agentInvocation.create({
|
|
880
|
+
data: {
|
|
881
|
+
teamRunId: teamRun.id,
|
|
882
|
+
workRequestId: activeRequest.id,
|
|
883
|
+
memberId: members[0].id,
|
|
884
|
+
workspaceId: workspace.id,
|
|
885
|
+
sessionId: session.id,
|
|
886
|
+
status: 'WAITING_ROOM_REPLY',
|
|
887
|
+
roomReplyReminderCount: 1,
|
|
888
|
+
nextRoomReplyReminderAt: new Date(Date.UTC(2026, 0, 1, 0, 1, 0)),
|
|
889
|
+
},
|
|
890
|
+
});
|
|
891
|
+
expect(lockService.acquire(activeInvocation.id, [`workspace:task:${task.id}:write`])).toBe(true);
|
|
892
|
+
const nextRequest = await createWorkRequest({
|
|
893
|
+
teamRunId: teamRun.id,
|
|
894
|
+
targetMemberId: members[1].id,
|
|
895
|
+
status: 'QUEUED',
|
|
896
|
+
});
|
|
897
|
+
const sessionManager = createSessionManagerMock();
|
|
898
|
+
sessionManager.stop.mockImplementation(async (sessionId) => {
|
|
899
|
+
await prisma.agentInvocation.update({
|
|
900
|
+
where: { id: activeInvocation.id },
|
|
901
|
+
data: {
|
|
902
|
+
status: 'CANCELLED',
|
|
903
|
+
nextRoomReplyReminderAt: null,
|
|
904
|
+
},
|
|
905
|
+
});
|
|
906
|
+
lockService.releaseByOwner(activeInvocation.id);
|
|
907
|
+
return prisma.session.update({
|
|
908
|
+
where: { id: sessionId },
|
|
909
|
+
data: { status: 'CANCELLED' },
|
|
910
|
+
});
|
|
911
|
+
});
|
|
912
|
+
service = new TeamSchedulerService(lockService, {
|
|
913
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
914
|
+
sessionManager,
|
|
915
|
+
getProviderById: createProviderLookup(),
|
|
916
|
+
});
|
|
917
|
+
const result = await service.stopMemberWork(teamRun.id, members[0].id);
|
|
918
|
+
expect(sessionManager.stop).toHaveBeenCalledWith(session.id);
|
|
919
|
+
expect(result.stoppedSessionIds).toEqual([session.id]);
|
|
920
|
+
expect(result.cancelledInvocationIds).toEqual([]);
|
|
921
|
+
expect(result.cancelledWorkRequestIds).toEqual([]);
|
|
922
|
+
expect(result.startedInvocations).toHaveLength(1);
|
|
923
|
+
expect(result.startedInvocations[0]).toMatchObject({
|
|
924
|
+
workRequestId: nextRequest.id,
|
|
925
|
+
memberId: members[1].id,
|
|
926
|
+
status: 'RUNNING',
|
|
927
|
+
});
|
|
928
|
+
await expect(prisma.session.findUnique({ where: { id: session.id } })).resolves.toMatchObject({
|
|
929
|
+
status: 'CANCELLED',
|
|
930
|
+
});
|
|
931
|
+
await expect(prisma.agentInvocation.findUnique({ where: { id: activeInvocation.id } })).resolves.toMatchObject({
|
|
932
|
+
status: 'CANCELLED',
|
|
933
|
+
nextRoomReplyReminderAt: null,
|
|
934
|
+
});
|
|
935
|
+
expect(lockService.listLocks()).toEqual([
|
|
936
|
+
{ key: `workspace:task:${task.id}:write`, ownerId: result.startedInvocations[0].id },
|
|
937
|
+
]);
|
|
938
|
+
});
|
|
939
|
+
it('does not start queued work when stopping a member with no active invocation and no queue cancellation', async () => {
|
|
940
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
941
|
+
memberCapabilities: [readOnlyCapabilities, readOnlyCapabilities],
|
|
942
|
+
});
|
|
943
|
+
const otherMemberQueued = await createWorkRequest({
|
|
944
|
+
teamRunId: teamRun.id,
|
|
945
|
+
targetMemberId: members[1].id,
|
|
946
|
+
status: 'QUEUED',
|
|
947
|
+
});
|
|
948
|
+
service = new TeamSchedulerService(lockService, {
|
|
949
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
950
|
+
sessionManager: createSessionManagerMock(),
|
|
951
|
+
getProviderById: createProviderLookup(),
|
|
952
|
+
});
|
|
953
|
+
const result = await service.stopMemberWork(teamRun.id, members[0].id);
|
|
954
|
+
expect(result).toEqual({
|
|
955
|
+
stoppedSessionIds: [],
|
|
956
|
+
cancelledInvocationIds: [],
|
|
957
|
+
cancelledWorkRequestIds: [],
|
|
958
|
+
startedInvocations: [],
|
|
959
|
+
});
|
|
960
|
+
await expect(prisma.workRequest.findUnique({ where: { id: otherMemberQueued.id } })).resolves.toMatchObject({
|
|
961
|
+
status: 'QUEUED',
|
|
962
|
+
});
|
|
963
|
+
await expect(prisma.agentInvocation.count({ where: { teamRunId: teamRun.id } })).resolves.toBe(0);
|
|
964
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
965
|
+
});
|
|
966
|
+
it('starts none-policy members in the shared workspace without workspace write or command locks', async () => {
|
|
967
|
+
const { workspace, teamRun, members } = await createTeamRunFixture({
|
|
968
|
+
memberCapabilities: [commandCapabilities, readOnlyCapabilities],
|
|
969
|
+
workspacePolicies: ['none', 'shared'],
|
|
970
|
+
});
|
|
971
|
+
const noneRequest = await createWorkRequest({
|
|
972
|
+
teamRunId: teamRun.id,
|
|
973
|
+
targetMemberId: members[0].id,
|
|
974
|
+
});
|
|
975
|
+
await createWorkRequest({
|
|
976
|
+
teamRunId: teamRun.id,
|
|
977
|
+
targetMemberId: members[1].id,
|
|
978
|
+
});
|
|
979
|
+
const sessionManager = createSessionManagerMock();
|
|
980
|
+
service = new TeamSchedulerService(lockService, {
|
|
981
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
982
|
+
sessionManager,
|
|
983
|
+
getProviderById: createProviderLookup(),
|
|
984
|
+
});
|
|
985
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
986
|
+
expect(invocations).toHaveLength(2);
|
|
987
|
+
expect(invocations.map((invocation) => invocation.workspaceId)).toEqual([workspace.id, workspace.id]);
|
|
988
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
989
|
+
const sameMemberRequest = await createWorkRequest({
|
|
990
|
+
teamRunId: teamRun.id,
|
|
991
|
+
targetMemberId: members[0].id,
|
|
992
|
+
});
|
|
993
|
+
await expect(service.startNextSessions(teamRun.id)).resolves.toEqual([]);
|
|
994
|
+
await expect(prisma.workRequest.findUnique({ where: { id: noneRequest.id } })).resolves.toMatchObject({
|
|
995
|
+
status: 'STARTED',
|
|
996
|
+
});
|
|
997
|
+
await expect(prisma.workRequest.findUnique({ where: { id: sameMemberRequest.id } })).resolves.toMatchObject({
|
|
998
|
+
status: 'QUEUED',
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
it('keeps shared writer locks on the stable task key after creating a real workspace', async () => {
|
|
1002
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
1003
|
+
memberCapabilities: [writeCapabilities, writeCapabilities],
|
|
1004
|
+
withWorkspace: false,
|
|
1005
|
+
});
|
|
1006
|
+
const first = await createWorkRequest({
|
|
1007
|
+
teamRunId: teamRun.id,
|
|
1008
|
+
targetMemberId: members[0].id,
|
|
1009
|
+
});
|
|
1010
|
+
const second = await createWorkRequest({
|
|
1011
|
+
teamRunId: teamRun.id,
|
|
1012
|
+
targetMemberId: members[1].id,
|
|
1013
|
+
});
|
|
1014
|
+
service = new TeamSchedulerService(lockService, {
|
|
1015
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1016
|
+
sessionManager: createSessionManagerMock(),
|
|
1017
|
+
getProviderById: createProviderLookup(),
|
|
1018
|
+
});
|
|
1019
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
1020
|
+
expect(invocations).toHaveLength(1);
|
|
1021
|
+
expect(invocations[0]).toMatchObject({
|
|
1022
|
+
workRequestId: first.id,
|
|
1023
|
+
workspaceId: expect.any(String),
|
|
1024
|
+
status: 'RUNNING',
|
|
1025
|
+
});
|
|
1026
|
+
expect(lockService.listLocks()).toEqual([
|
|
1027
|
+
{ key: `workspace:task:${task.id}:write`, ownerId: invocations[0].id },
|
|
1028
|
+
]);
|
|
1029
|
+
await expect(prisma.workspace.count({ where: { taskId: task.id, status: 'ACTIVE' } })).resolves.toBe(1);
|
|
1030
|
+
await expect(prisma.workRequest.findUnique({ where: { id: second.id } })).resolves.toMatchObject({
|
|
1031
|
+
status: 'QUEUED',
|
|
1032
|
+
});
|
|
1033
|
+
await expect(service.startNextSessions(teamRun.id)).resolves.toEqual([]);
|
|
1034
|
+
});
|
|
1035
|
+
it('deduplicates shared workspace creation across concurrent different-member session starts', async () => {
|
|
1036
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
1037
|
+
memberCapabilities: [readOnlyCapabilities, readOnlyCapabilities],
|
|
1038
|
+
withWorkspace: false,
|
|
1039
|
+
});
|
|
1040
|
+
const first = await createWorkRequest({
|
|
1041
|
+
teamRunId: teamRun.id,
|
|
1042
|
+
targetMemberId: members[0].id,
|
|
1043
|
+
});
|
|
1044
|
+
const second = await createWorkRequest({
|
|
1045
|
+
teamRunId: teamRun.id,
|
|
1046
|
+
targetMemberId: members[1].id,
|
|
1047
|
+
});
|
|
1048
|
+
const creationGate = createDeferred();
|
|
1049
|
+
let createStarted = false;
|
|
1050
|
+
const workspaceService = {
|
|
1051
|
+
create: vi.fn(async (taskId) => {
|
|
1052
|
+
createStarted = true;
|
|
1053
|
+
await creationGate.promise;
|
|
1054
|
+
return prisma.workspace.create({
|
|
1055
|
+
data: {
|
|
1056
|
+
taskId,
|
|
1057
|
+
branchName: 'team-shared-concurrent',
|
|
1058
|
+
worktreePath: path.join(testDir, 'created-workspace-concurrent'),
|
|
1059
|
+
status: 'ACTIVE',
|
|
1060
|
+
},
|
|
1061
|
+
});
|
|
1062
|
+
}),
|
|
1063
|
+
};
|
|
1064
|
+
const sessionManager = createSessionManagerMock();
|
|
1065
|
+
const firstService = new TeamSchedulerService(lockService, {
|
|
1066
|
+
workspaceService,
|
|
1067
|
+
sessionManager,
|
|
1068
|
+
getProviderById: createProviderLookup(),
|
|
1069
|
+
});
|
|
1070
|
+
const secondService = new TeamSchedulerService(lockService, {
|
|
1071
|
+
workspaceService,
|
|
1072
|
+
sessionManager,
|
|
1073
|
+
getProviderById: createProviderLookup(),
|
|
1074
|
+
});
|
|
1075
|
+
const firstStart = firstService.startNextSessions(teamRun.id);
|
|
1076
|
+
await waitForCondition(() => createStarted);
|
|
1077
|
+
const secondStart = secondService.startNextSessions(teamRun.id);
|
|
1078
|
+
creationGate.resolve();
|
|
1079
|
+
const started = (await Promise.all([firstStart, secondStart])).flat();
|
|
1080
|
+
expect(started).toHaveLength(2);
|
|
1081
|
+
expect(new Set(started.map((invocation) => invocation.workRequestId))).toEqual(new Set([first.id, second.id]));
|
|
1082
|
+
const workspaceIds = started.map((invocation) => invocation.workspaceId);
|
|
1083
|
+
expect(new Set(workspaceIds).size).toBe(1);
|
|
1084
|
+
expect(workspaceIds[0]).toEqual(expect.any(String));
|
|
1085
|
+
expect(workspaceService.create).toHaveBeenCalledTimes(1);
|
|
1086
|
+
await expect(prisma.workspace.count({ where: { taskId: task.id, status: 'ACTIVE' } })).resolves.toBe(1);
|
|
1087
|
+
const sessions = await prisma.session.findMany({
|
|
1088
|
+
where: { id: { in: started.map((invocation) => invocation.sessionId) } },
|
|
1089
|
+
orderBy: { createdAt: 'asc' },
|
|
1090
|
+
});
|
|
1091
|
+
expect(sessions).toHaveLength(2);
|
|
1092
|
+
expect(new Set(sessions.map((session) => session.workspaceId))).toEqual(new Set([workspaceIds[0]]));
|
|
1093
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
1094
|
+
});
|
|
1095
|
+
it('fails clearly and leaves no session or lock when a provider is missing', async () => {
|
|
1096
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
1097
|
+
memberCapabilities: [writeCapabilities],
|
|
1098
|
+
withWorkspace: false,
|
|
1099
|
+
});
|
|
1100
|
+
const request = await createWorkRequest({
|
|
1101
|
+
teamRunId: teamRun.id,
|
|
1102
|
+
targetMemberId: members[0].id,
|
|
1103
|
+
});
|
|
1104
|
+
const workspaceService = createWorkspaceServiceMock();
|
|
1105
|
+
const sessionManager = createSessionManagerMock();
|
|
1106
|
+
service = new TeamSchedulerService(lockService, {
|
|
1107
|
+
workspaceService,
|
|
1108
|
+
sessionManager,
|
|
1109
|
+
getProviderById: vi.fn(() => null),
|
|
1110
|
+
});
|
|
1111
|
+
await expect(service.startNextSessions(teamRun.id)).rejects.toMatchObject({
|
|
1112
|
+
code: 'PROVIDER_NOT_FOUND',
|
|
1113
|
+
message: `Provider not found: ${members[0].providerId}`,
|
|
1114
|
+
});
|
|
1115
|
+
expect(workspaceService.create).not.toHaveBeenCalled();
|
|
1116
|
+
expect(sessionManager.create).not.toHaveBeenCalled();
|
|
1117
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
1118
|
+
await expect(prisma.session.count()).resolves.toBe(0);
|
|
1119
|
+
await expect(prisma.agentInvocation.count()).resolves.toBe(0);
|
|
1120
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
1121
|
+
status: 'QUEUED',
|
|
1122
|
+
});
|
|
1123
|
+
});
|
|
1124
|
+
it('marks invocation and session failed and releases locks when session start fails', async () => {
|
|
1125
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
1126
|
+
memberCapabilities: [writeCapabilities],
|
|
1127
|
+
withWorkspace: false,
|
|
1128
|
+
});
|
|
1129
|
+
const request = await createWorkRequest({
|
|
1130
|
+
teamRunId: teamRun.id,
|
|
1131
|
+
targetMemberId: members[0].id,
|
|
1132
|
+
});
|
|
1133
|
+
service = new TeamSchedulerService(lockService, {
|
|
1134
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1135
|
+
sessionManager: createSessionManagerMock({ failStart: true }),
|
|
1136
|
+
getProviderById: createProviderLookup(),
|
|
1137
|
+
});
|
|
1138
|
+
await expect(service.startNextSessions(teamRun.id)).rejects.toThrow('session start failed');
|
|
1139
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
1140
|
+
const invocation = await prisma.agentInvocation.findFirstOrThrow({
|
|
1141
|
+
where: { workRequestId: request.id },
|
|
1142
|
+
});
|
|
1143
|
+
expect(invocation).toMatchObject({
|
|
1144
|
+
status: 'FAILED',
|
|
1145
|
+
workspaceId: expect.any(String),
|
|
1146
|
+
sessionId: expect.any(String),
|
|
1147
|
+
});
|
|
1148
|
+
await expect(prisma.session.findUnique({ where: { id: invocation.sessionId } })).resolves.toMatchObject({
|
|
1149
|
+
status: 'FAILED',
|
|
1150
|
+
});
|
|
1151
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
1152
|
+
status: 'STARTED',
|
|
1153
|
+
});
|
|
1154
|
+
await expect(service.startNextSessions(teamRun.id)).resolves.toEqual([]);
|
|
1155
|
+
await expect(prisma.agentInvocation.count({ where: { workRequestId: request.id } })).resolves.toBe(1);
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
//# sourceMappingURL=team-scheduler.service.test.js.map
|