agent-tower 0.4.16-beta.0 → 0.4.16-beta.4
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/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/http-client.d.ts +5 -1
- package/dist/mcp/http-client.d.ts.map +1 -1
- package/dist/mcp/http-client.js +13 -3
- package/dist/mcp/http-client.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +33 -3
- 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 +3 -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/tasks.js +2 -2
- package/dist/routes/tasks.js.map +1 -1
- package/dist/routes/team-runs.d.ts.map +1 -1
- package/dist/routes/team-runs.js +36 -9
- package/dist/routes/team-runs.js.map +1 -1
- 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__/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-reconciler.service.test.js +720 -28
- package/dist/services/__tests__/team-reconciler.service.test.js.map +1 -1
- package/dist/services/__tests__/team-run.service.test.js +416 -0
- package/dist/services/__tests__/team-run.service.test.js.map +1 -1
- package/dist/services/__tests__/team-scheduler.service.test.js +680 -26
- package/dist/services/__tests__/team-scheduler.service.test.js.map +1 -1
- 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 +12 -0
- package/dist/services/session-manager.d.ts.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 +3 -0
- package/dist/services/team-lock.service.d.ts.map +1 -1
- package/dist/services/team-lock.service.js +11 -0
- package/dist/services/team-lock.service.js.map +1 -1
- package/dist/services/team-run.service.d.ts +34 -1
- package/dist/services/team-run.service.d.ts.map +1 -1
- package/dist/services/team-run.service.js +370 -30
- package/dist/services/team-run.service.js.map +1 -1
- package/dist/services/team-scheduler.service.d.ts +22 -1
- package/dist/services/team-scheduler.service.d.ts.map +1 -1
- package/dist/services/team-scheduler.service.js +148 -33
- package/dist/services/team-scheduler.service.js.map +1 -1
- 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/web/assets/{AgentDemoPage-p9YI4_l4.js → AgentDemoPage-BhDnxdmh.js} +1 -1
- package/dist/web/assets/{DemoPage-B5DTSEbS.js → DemoPage-CJBc0NZf.js} +1 -1
- package/dist/web/assets/{GeneralSettingsPage-Cspr7Vol.js → GeneralSettingsPage-CEjDPmtD.js} +1 -1
- package/dist/web/assets/MemberAvatar-DxRCLAoK.js +1 -0
- package/dist/web/assets/NotificationSettingsPage-i77lmSic.js +1 -0
- package/dist/web/assets/{ProfileSettingsPage-CNugU40a.js → ProfileSettingsPage-DcGLD5O-.js} +1 -1
- package/dist/web/assets/ProjectKanbanPage-CX-NY7hx.js +89 -0
- package/dist/web/assets/ProjectSettingsPage-BBfroQPA.js +2 -0
- package/dist/web/assets/{ProviderSettingsPage-D_KWkgRM.js → ProviderSettingsPage-BdxbO1E9.js} +12 -12
- package/dist/web/assets/TeamSettingsPage-CTWGO79W.js +1 -0
- package/dist/web/assets/agent-tower-logo-COx9gy77.png +0 -0
- package/dist/web/assets/{button-B6JaSbDB.js → button-RDdre_kF.js} +1 -1
- package/dist/web/assets/{chevron-down-CACy4UFq.js → chevron-down-Cfeapk0v.js} +1 -1
- package/dist/web/assets/{chevron-right-DFWfnDJY.js → chevron-right-BE6LVCii.js} +1 -1
- package/dist/web/assets/{chevron-up-CGlf6jzw.js → chevron-up-CEEz4jJv.js} +1 -1
- package/dist/web/assets/{circle-check-DMK8auwb.js → circle-check-0imI5gEL.js} +1 -1
- package/dist/web/assets/{code-block-OCS4YCEC-Hn75KHRK.js → code-block-OCS4YCEC-D4c9zDcq.js} +1 -1
- package/dist/web/assets/{confirm-dialog-DHI2f7Ni.js → confirm-dialog--HDqEa-R.js} +1 -1
- package/dist/web/assets/folder-picker-CKfogW-o.js +1 -0
- package/dist/web/assets/index-BHmOCKAn.css +1 -0
- package/dist/web/assets/{index-BFAA3PTl.js → index-DbGCpy8E.js} +9 -9
- package/dist/web/assets/loader-circle-BAUFMewp.js +1 -0
- package/dist/web/assets/{log-adapter-CeKrvZcz.js → log-adapter-DKKM3sxS.js} +1 -1
- package/dist/web/assets/{mermaid-NOHMQCX5-DJFgrXPd.js → mermaid-NOHMQCX5-BkaKG_2K.js} +4 -4
- package/dist/web/assets/{modal-B5IRN7QI.js → modal-3rdeMVPn.js} +1 -1
- package/dist/web/assets/{pencil-CJY6Ahn7.js → pencil-CqWv0WcO.js} +1 -1
- package/dist/web/assets/{select-BPZZlla1.js → select-DHVfUr22.js} +1 -1
- package/dist/web/assets/upload-D3aqtSCY.js +1 -0
- package/dist/web/assets/{use-profiles-C2k04ICZ.js → use-profiles-BznmWvqM.js} +1 -1
- package/dist/web/assets/{use-providers-C7fIDWzP.js → use-providers-DIHUIuEZ.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/types.d.ts +13 -1
- 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/edge.js +11 -5
- package/node_modules/@prisma/client/.prisma/client/index-browser.js +6 -0
- package/node_modules/@prisma/client/.prisma/client/index.d.ts +1309 -14
- package/node_modules/@prisma/client/.prisma/client/index.js +11 -5
- package/node_modules/@prisma/client/.prisma/client/package.json +1 -1
- package/node_modules/@prisma/client/.prisma/client/schema.prisma +70 -52
- package/node_modules/@prisma/client/.prisma/client/wasm.js +6 -0
- package/package.json +1 -1
- package/prisma/migrations/20260515000000_add_workspace_preview_target/migration.sql +2 -0
- package/prisma/migrations/20260526000000_add_team_run_main_and_dedicated_workspaces/migration.sql +21 -0
- package/prisma/migrations/20260529000000_add_team_member_queue_management_policy/migration.sql +2 -0
- package/prisma/schema.prisma +29 -11
- package/dist/web/assets/NotificationSettingsPage-C9VfrRr-.js +0 -1
- package/dist/web/assets/ProjectKanbanPage-CkGNuqxq.js +0 -87
- package/dist/web/assets/ProjectSettingsPage-f1dg0XMf.js +0 -2
- package/dist/web/assets/TeamSettingsPage-B6WciZyi.js +0 -1
- package/dist/web/assets/circle-alert-BSAUEd9O.js +0 -1
- package/dist/web/assets/folder-picker-CtQkbWfa.js +0 -1
- package/dist/web/assets/index-mBCb67dB.css +0 -1
- package/dist/web/assets/loader-circle-CkDnf8ST.js +0 -1
- package/dist/web/assets/use-projects-BxuE-ulT.js +0 -1
|
@@ -4,7 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
-
import { AgentType } from '../../types/index.js';
|
|
7
|
+
import { AgentType, TaskStatus } from '../../types/index.js';
|
|
8
8
|
import { TeamLockService } from '../team-lock.service.js';
|
|
9
9
|
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-tower-team-scheduler-'));
|
|
10
10
|
const dbPath = path.join(testDir, 'test.db');
|
|
@@ -40,7 +40,7 @@ const commandCapabilities = {
|
|
|
40
40
|
function stringifyJson(value) {
|
|
41
41
|
return JSON.stringify(value);
|
|
42
42
|
}
|
|
43
|
-
async function createTask(title = 'Team scheduler task') {
|
|
43
|
+
async function createTask(title = 'Team scheduler task', status = TaskStatus.TODO) {
|
|
44
44
|
const project = await prisma.project.create({
|
|
45
45
|
data: {
|
|
46
46
|
name: `${title} project`,
|
|
@@ -51,12 +51,13 @@ async function createTask(title = 'Team scheduler task') {
|
|
|
51
51
|
data: {
|
|
52
52
|
title,
|
|
53
53
|
projectId: project.id,
|
|
54
|
+
status,
|
|
54
55
|
},
|
|
55
56
|
});
|
|
56
57
|
return { project, task };
|
|
57
58
|
}
|
|
58
59
|
async function createTeamRunFixture(options = {}) {
|
|
59
|
-
const { project, task } = await createTask();
|
|
60
|
+
const { project, task } = await createTask('Team scheduler task', options.taskStatus);
|
|
60
61
|
const teamRun = await prisma.teamRun.create({
|
|
61
62
|
data: {
|
|
62
63
|
taskId: task.id,
|
|
@@ -87,6 +88,7 @@ async function createTeamRunFixture(options = {}) {
|
|
|
87
88
|
workspacePolicy: options.workspacePolicies?.[index] ?? 'shared',
|
|
88
89
|
triggerPolicy: 'MENTION_ONLY',
|
|
89
90
|
sessionPolicy: options.sessionPolicies?.[index] ?? 'new_per_request',
|
|
91
|
+
queueManagementPolicy: options.queueManagementPolicies?.[index] ?? 'own_only',
|
|
90
92
|
avatar: null,
|
|
91
93
|
},
|
|
92
94
|
}));
|
|
@@ -100,7 +102,7 @@ async function createWorkRequest(options) {
|
|
|
100
102
|
requesterMemberId: null,
|
|
101
103
|
requesterType: 'user',
|
|
102
104
|
targetMemberId: options.targetMemberId,
|
|
103
|
-
triggerMessageId: `message-${Math.random().toString(16).slice(2)}`,
|
|
105
|
+
triggerMessageId: options.triggerMessageId ?? `message-${Math.random().toString(16).slice(2)}`,
|
|
104
106
|
instruction: options.instruction ?? 'Please do the work',
|
|
105
107
|
ifBusy: options.ifBusy ?? 'queue',
|
|
106
108
|
cancelQueued: options.cancelQueued ?? false,
|
|
@@ -299,7 +301,7 @@ describe('TeamSchedulerService', () => {
|
|
|
299
301
|
statusCode: 400,
|
|
300
302
|
});
|
|
301
303
|
});
|
|
302
|
-
it('
|
|
304
|
+
it('rejects TeamRun WorkRequest cancellation without member scope', async () => {
|
|
303
305
|
const { teamRun, members } = await createTeamRunFixture();
|
|
304
306
|
const pending = await createWorkRequest({
|
|
305
307
|
teamRunId: teamRun.id,
|
|
@@ -311,8 +313,125 @@ describe('TeamSchedulerService', () => {
|
|
|
311
313
|
targetMemberId: members[0].id,
|
|
312
314
|
status: 'QUEUED',
|
|
313
315
|
});
|
|
314
|
-
await expect(service.cancelWorkRequest(pending.id)).
|
|
315
|
-
|
|
316
|
+
await expect(service.cancelWorkRequest(pending.id, undefined)).rejects.toMatchObject({
|
|
317
|
+
code: 'VALIDATION_ERROR',
|
|
318
|
+
statusCode: 400,
|
|
319
|
+
});
|
|
320
|
+
await expect(service.cancelWorkRequest(queued.id, {})).rejects.toMatchObject({
|
|
321
|
+
code: 'VALIDATION_ERROR',
|
|
322
|
+
statusCode: 400,
|
|
323
|
+
});
|
|
324
|
+
await expect(prisma.workRequest.findMany({ where: { id: { in: [pending.id, queued.id] } } })).resolves.toEqual(expect.arrayContaining([
|
|
325
|
+
expect.objectContaining({ id: pending.id, status: 'PENDING_APPROVAL' }),
|
|
326
|
+
expect.objectContaining({ id: queued.id, status: 'QUEUED' }),
|
|
327
|
+
]));
|
|
328
|
+
});
|
|
329
|
+
it('allows a member to cancel their own pending or queued WorkRequest', 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
|
+
await expect(service.cancelWorkRequest(request.id, {
|
|
337
|
+
teamRunId: teamRun.id,
|
|
338
|
+
requesterMemberId: members[0].id,
|
|
339
|
+
})).resolves.toMatchObject({ status: 'CANCELLED' });
|
|
340
|
+
});
|
|
341
|
+
it('allows members with team_pending queueManagementPolicy to cancel TeamRun queue requests for others', async () => {
|
|
342
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
343
|
+
memberCapabilities: [readOnlyCapabilities, readOnlyCapabilities],
|
|
344
|
+
queueManagementPolicies: ['team_pending', 'own_only'],
|
|
345
|
+
});
|
|
346
|
+
const request = await createWorkRequest({
|
|
347
|
+
teamRunId: teamRun.id,
|
|
348
|
+
targetMemberId: members[1].id,
|
|
349
|
+
status: 'PENDING_APPROVAL',
|
|
350
|
+
});
|
|
351
|
+
await expect(service.cancelWorkRequest(request.id, {
|
|
352
|
+
teamRunId: teamRun.id,
|
|
353
|
+
requesterMemberId: members[0].id,
|
|
354
|
+
})).resolves.toMatchObject({ status: 'CANCELLED' });
|
|
355
|
+
});
|
|
356
|
+
it('does not use stopMemberWork capability as queue cancellation permission', async () => {
|
|
357
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
358
|
+
memberCapabilities: [
|
|
359
|
+
{ ...readOnlyCapabilities, stopMemberWork: true },
|
|
360
|
+
readOnlyCapabilities,
|
|
361
|
+
],
|
|
362
|
+
queueManagementPolicies: ['own_only', 'own_only'],
|
|
363
|
+
});
|
|
364
|
+
const request = await createWorkRequest({
|
|
365
|
+
teamRunId: teamRun.id,
|
|
366
|
+
targetMemberId: members[1].id,
|
|
367
|
+
status: 'QUEUED',
|
|
368
|
+
});
|
|
369
|
+
await expect(service.cancelWorkRequest(request.id, {
|
|
370
|
+
teamRunId: teamRun.id,
|
|
371
|
+
requesterMemberId: members[0].id,
|
|
372
|
+
})).rejects.toMatchObject({
|
|
373
|
+
code: 'FORBIDDEN',
|
|
374
|
+
statusCode: 403,
|
|
375
|
+
});
|
|
376
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
377
|
+
status: 'QUEUED',
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
it('rejects restricted cancellation for another member without queue management capability', async () => {
|
|
381
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
382
|
+
memberCapabilities: [readOnlyCapabilities, readOnlyCapabilities],
|
|
383
|
+
});
|
|
384
|
+
const request = await createWorkRequest({
|
|
385
|
+
teamRunId: teamRun.id,
|
|
386
|
+
targetMemberId: members[1].id,
|
|
387
|
+
status: 'QUEUED',
|
|
388
|
+
});
|
|
389
|
+
await expect(service.cancelWorkRequest(request.id, {
|
|
390
|
+
teamRunId: teamRun.id,
|
|
391
|
+
requesterMemberId: members[0].id,
|
|
392
|
+
})).rejects.toMatchObject({
|
|
393
|
+
code: 'FORBIDDEN',
|
|
394
|
+
statusCode: 403,
|
|
395
|
+
});
|
|
396
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
397
|
+
status: 'QUEUED',
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
it('does not cancel a WorkRequest outside the bound TeamRun', async () => {
|
|
401
|
+
const first = await createTeamRunFixture();
|
|
402
|
+
const second = await createTeamRunFixture();
|
|
403
|
+
const request = await createWorkRequest({
|
|
404
|
+
teamRunId: second.teamRun.id,
|
|
405
|
+
targetMemberId: second.members[0].id,
|
|
406
|
+
status: 'QUEUED',
|
|
407
|
+
});
|
|
408
|
+
await expect(service.cancelWorkRequest(request.id, {
|
|
409
|
+
teamRunId: first.teamRun.id,
|
|
410
|
+
requesterMemberId: first.members[0].id,
|
|
411
|
+
})).rejects.toMatchObject({
|
|
412
|
+
code: 'NOT_FOUND',
|
|
413
|
+
statusCode: 404,
|
|
414
|
+
});
|
|
415
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
416
|
+
status: 'QUEUED',
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
it('requires requester member identity for TeamRun-scoped cancellation', async () => {
|
|
420
|
+
const { teamRun, members } = await createTeamRunFixture();
|
|
421
|
+
const request = await createWorkRequest({
|
|
422
|
+
teamRunId: teamRun.id,
|
|
423
|
+
targetMemberId: members[0].id,
|
|
424
|
+
status: 'QUEUED',
|
|
425
|
+
});
|
|
426
|
+
await expect(service.cancelWorkRequest(request.id, {
|
|
427
|
+
teamRunId: teamRun.id,
|
|
428
|
+
})).rejects.toMatchObject({
|
|
429
|
+
code: 'VALIDATION_ERROR',
|
|
430
|
+
statusCode: 400,
|
|
431
|
+
});
|
|
432
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
433
|
+
status: 'QUEUED',
|
|
434
|
+
});
|
|
316
435
|
});
|
|
317
436
|
it('does not cancel a started WorkRequest', async () => {
|
|
318
437
|
const { teamRun, members } = await createTeamRunFixture();
|
|
@@ -321,7 +440,10 @@ describe('TeamSchedulerService', () => {
|
|
|
321
440
|
targetMemberId: members[0].id,
|
|
322
441
|
status: 'STARTED',
|
|
323
442
|
});
|
|
324
|
-
await expect(service.cancelWorkRequest(request.id
|
|
443
|
+
await expect(service.cancelWorkRequest(request.id, {
|
|
444
|
+
teamRunId: teamRun.id,
|
|
445
|
+
requesterMemberId: members[0].id,
|
|
446
|
+
})).rejects.toMatchObject({
|
|
325
447
|
code: 'INVALID_STATE_TRANSITION',
|
|
326
448
|
statusCode: 400,
|
|
327
449
|
});
|
|
@@ -342,7 +464,10 @@ describe('TeamSchedulerService', () => {
|
|
|
342
464
|
});
|
|
343
465
|
return originalTransaction(arg, ...rest);
|
|
344
466
|
});
|
|
345
|
-
await expect(service.cancelWorkRequest(request.id
|
|
467
|
+
await expect(service.cancelWorkRequest(request.id, {
|
|
468
|
+
teamRunId: teamRun.id,
|
|
469
|
+
requesterMemberId: members[0].id,
|
|
470
|
+
})).rejects.toMatchObject({
|
|
346
471
|
code: 'INVALID_STATE_TRANSITION',
|
|
347
472
|
statusCode: 400,
|
|
348
473
|
message: expect.stringContaining('STARTED'),
|
|
@@ -617,26 +742,124 @@ describe('TeamSchedulerService', () => {
|
|
|
617
742
|
}),
|
|
618
743
|
]);
|
|
619
744
|
});
|
|
620
|
-
it('
|
|
621
|
-
const { teamRun, members } = await createTeamRunFixture({
|
|
622
|
-
|
|
745
|
+
it('starts dedicated members in child workspaces without shared workspace locks', async () => {
|
|
746
|
+
const { workspace: mainWorkspace, teamRun, members } = await createTeamRunFixture({
|
|
747
|
+
memberCapabilities: [writeCapabilities, writeCapabilities],
|
|
748
|
+
workspacePolicies: ['dedicated', 'dedicated'],
|
|
623
749
|
});
|
|
624
|
-
const
|
|
750
|
+
const first = await createWorkRequest({
|
|
625
751
|
teamRunId: teamRun.id,
|
|
626
752
|
targetMemberId: members[0].id,
|
|
627
753
|
});
|
|
754
|
+
const second = await createWorkRequest({
|
|
755
|
+
teamRunId: teamRun.id,
|
|
756
|
+
targetMemberId: members[1].id,
|
|
757
|
+
});
|
|
758
|
+
const childByMemberId = new Map();
|
|
759
|
+
const workspaceService = {
|
|
760
|
+
create: vi.fn(),
|
|
761
|
+
getOrCreateMainWorkspace: vi.fn(async () => mainWorkspace),
|
|
762
|
+
getOrCreateDedicatedWorkspace: vi.fn(async (_teamRunId, memberId) => {
|
|
763
|
+
const existing = childByMemberId.get(memberId);
|
|
764
|
+
if (existing) {
|
|
765
|
+
return existing;
|
|
766
|
+
}
|
|
767
|
+
const workspace = await prisma.workspace.create({
|
|
768
|
+
data: {
|
|
769
|
+
taskId: teamRun.taskId,
|
|
770
|
+
parentWorkspaceId: mainWorkspace.id,
|
|
771
|
+
ownerMemberId: memberId,
|
|
772
|
+
branchName: `dedicated-${memberId.slice(0, 8)}`,
|
|
773
|
+
worktreePath: path.join(testDir, `dedicated-${memberId}`),
|
|
774
|
+
status: 'ACTIVE',
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
childByMemberId.set(memberId, workspace);
|
|
778
|
+
return workspace;
|
|
779
|
+
}),
|
|
780
|
+
};
|
|
781
|
+
service = new TeamSchedulerService(lockService, {
|
|
782
|
+
workspaceService,
|
|
783
|
+
sessionManager: createSessionManagerMock(),
|
|
784
|
+
getProviderById: createProviderLookup(),
|
|
785
|
+
});
|
|
628
786
|
await expect(service.planNext(teamRun.id)).resolves.toEqual([
|
|
629
787
|
expect.objectContaining({
|
|
630
|
-
workRequestId:
|
|
631
|
-
canStart:
|
|
632
|
-
|
|
788
|
+
workRequestId: first.id,
|
|
789
|
+
canStart: true,
|
|
790
|
+
lockKeys: [],
|
|
791
|
+
workspaceId: null,
|
|
792
|
+
}),
|
|
793
|
+
expect.objectContaining({
|
|
794
|
+
workRequestId: second.id,
|
|
795
|
+
canStart: true,
|
|
796
|
+
lockKeys: [],
|
|
797
|
+
workspaceId: null,
|
|
633
798
|
}),
|
|
634
799
|
]);
|
|
635
|
-
await
|
|
636
|
-
|
|
637
|
-
|
|
800
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
801
|
+
expect(invocations).toHaveLength(2);
|
|
802
|
+
expect(new Set(invocations.map((invocation) => invocation.workspaceId)).size).toBe(2);
|
|
803
|
+
expect(new Set(invocations.map((invocation) => invocation.workRequestId))).toEqual(new Set([
|
|
804
|
+
first.id,
|
|
805
|
+
second.id,
|
|
806
|
+
]));
|
|
807
|
+
expect(workspaceService.getOrCreateDedicatedWorkspace).toHaveBeenCalledTimes(2);
|
|
808
|
+
expect(workspaceService.create).not.toHaveBeenCalled();
|
|
809
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
810
|
+
const childWorkspaces = await prisma.workspace.findMany({
|
|
811
|
+
where: { parentWorkspaceId: mainWorkspace.id },
|
|
812
|
+
orderBy: { ownerMemberId: 'asc' },
|
|
813
|
+
});
|
|
814
|
+
expect(childWorkspaces).toHaveLength(2);
|
|
815
|
+
expect(new Set(childWorkspaces.map((workspace) => workspace.ownerMemberId))).toEqual(new Set([
|
|
816
|
+
members[0].id,
|
|
817
|
+
members[1].id,
|
|
818
|
+
]));
|
|
819
|
+
});
|
|
820
|
+
it('records the dedicated child workspace for queued no-session invocations', async () => {
|
|
821
|
+
const { workspace: mainWorkspace, teamRun, members } = await createTeamRunFixture({
|
|
822
|
+
memberCapabilities: [writeCapabilities],
|
|
823
|
+
workspacePolicies: ['dedicated'],
|
|
824
|
+
});
|
|
825
|
+
const request = await createWorkRequest({
|
|
826
|
+
teamRunId: teamRun.id,
|
|
827
|
+
targetMemberId: members[0].id,
|
|
828
|
+
});
|
|
829
|
+
const childWorkspace = await prisma.workspace.create({
|
|
830
|
+
data: {
|
|
831
|
+
taskId: teamRun.taskId,
|
|
832
|
+
parentWorkspaceId: mainWorkspace.id,
|
|
833
|
+
ownerMemberId: members[0].id,
|
|
834
|
+
branchName: 'dedicated-queued',
|
|
835
|
+
worktreePath: path.join(testDir, 'dedicated-queued'),
|
|
836
|
+
status: 'ACTIVE',
|
|
837
|
+
},
|
|
838
|
+
});
|
|
839
|
+
const workspaceService = {
|
|
840
|
+
create: vi.fn(),
|
|
841
|
+
getOrCreateMainWorkspace: vi.fn(async () => mainWorkspace),
|
|
842
|
+
getOrCreateDedicatedWorkspace: vi.fn(async () => childWorkspace),
|
|
843
|
+
};
|
|
844
|
+
service = new TeamSchedulerService(lockService, {
|
|
845
|
+
workspaceService,
|
|
846
|
+
sessionManager: createSessionManagerMock(),
|
|
847
|
+
getProviderById: createProviderLookup(),
|
|
848
|
+
});
|
|
849
|
+
const invocations = await service.startNext(teamRun.id);
|
|
850
|
+
expect(invocations).toHaveLength(1);
|
|
851
|
+
expect(invocations[0]).toMatchObject({
|
|
852
|
+
workRequestId: request.id,
|
|
853
|
+
memberId: members[0].id,
|
|
854
|
+
workspaceId: childWorkspace.id,
|
|
855
|
+
sessionId: null,
|
|
638
856
|
status: 'QUEUED',
|
|
639
857
|
});
|
|
858
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
859
|
+
expect(workspaceService.getOrCreateDedicatedWorkspace).toHaveBeenCalledWith(teamRun.id, members[0].id);
|
|
860
|
+
await expect(prisma.agentInvocation.findUnique({ where: { id: invocations[0].id } })).resolves.toMatchObject({
|
|
861
|
+
workspaceId: childWorkspace.id,
|
|
862
|
+
});
|
|
640
863
|
});
|
|
641
864
|
it('cancels only queued requests for the same member when cancelQueued is set', async () => {
|
|
642
865
|
const { teamRun, members } = await createTeamRunFixture();
|
|
@@ -702,6 +925,88 @@ describe('TeamSchedulerService', () => {
|
|
|
702
925
|
status: 'RUNNING',
|
|
703
926
|
});
|
|
704
927
|
});
|
|
928
|
+
it('builds session prompt attachment context from the trigger RoomMessage attachmentIds', async () => {
|
|
929
|
+
const { task, teamRun, members } = await createTeamRunFixture({ withWorkspace: false });
|
|
930
|
+
const attachment = await prisma.attachment.create({
|
|
931
|
+
data: {
|
|
932
|
+
originalName: 'reference.png',
|
|
933
|
+
mimeType: 'image/png',
|
|
934
|
+
sizeBytes: 256,
|
|
935
|
+
storagePath: path.join(testDir, 'reference.png'),
|
|
936
|
+
hash: 'scheduler-attachment-context-hash',
|
|
937
|
+
},
|
|
938
|
+
});
|
|
939
|
+
const message = await prisma.roomMessage.create({
|
|
940
|
+
data: {
|
|
941
|
+
teamRunId: teamRun.id,
|
|
942
|
+
senderType: 'user',
|
|
943
|
+
kind: 'work_request',
|
|
944
|
+
content: 'Use this reference',
|
|
945
|
+
mentions: stringifyJson([{ memberId: members[0].id, label: 'Member 1' }]),
|
|
946
|
+
workRequestIds: stringifyJson([]),
|
|
947
|
+
artifactRefs: stringifyJson([]),
|
|
948
|
+
attachmentIds: stringifyJson([attachment.id]),
|
|
949
|
+
},
|
|
950
|
+
});
|
|
951
|
+
await createWorkRequest({
|
|
952
|
+
teamRunId: teamRun.id,
|
|
953
|
+
targetMemberId: members[0].id,
|
|
954
|
+
instruction: 'Use this reference',
|
|
955
|
+
triggerMessageId: message.id,
|
|
956
|
+
});
|
|
957
|
+
const workspaceService = createWorkspaceServiceMock();
|
|
958
|
+
const sessionManager = createSessionManagerMock();
|
|
959
|
+
service = new TeamSchedulerService(lockService, {
|
|
960
|
+
workspaceService,
|
|
961
|
+
sessionManager,
|
|
962
|
+
getProviderById: createProviderLookup(),
|
|
963
|
+
});
|
|
964
|
+
await service.startNextSessions(teamRun.id);
|
|
965
|
+
expect(workspaceService.create).toHaveBeenCalledWith(task.id);
|
|
966
|
+
expect(sessionManager.create).toHaveBeenCalledWith(expect.any(String), AgentType.CODEX, `Role 1\n\nTask:\nUse this reference\n\nAttachments:\n`, 'DEFAULT', members[0].providerId);
|
|
967
|
+
});
|
|
968
|
+
it('does not duplicate session prompt attachment context when the WorkRequest instruction already includes the storage path', async () => {
|
|
969
|
+
const { task, teamRun, members } = await createTeamRunFixture({ withWorkspace: false });
|
|
970
|
+
const attachment = await prisma.attachment.create({
|
|
971
|
+
data: {
|
|
972
|
+
originalName: 'reference.png',
|
|
973
|
+
mimeType: 'image/png',
|
|
974
|
+
sizeBytes: 256,
|
|
975
|
+
storagePath: path.join(testDir, 'reference-dedup.png'),
|
|
976
|
+
hash: 'scheduler-attachment-context-dedup-hash',
|
|
977
|
+
},
|
|
978
|
+
});
|
|
979
|
+
const instruction = `Use this reference\n\n`;
|
|
980
|
+
const message = await prisma.roomMessage.create({
|
|
981
|
+
data: {
|
|
982
|
+
teamRunId: teamRun.id,
|
|
983
|
+
senderType: 'user',
|
|
984
|
+
kind: 'work_request',
|
|
985
|
+
content: instruction,
|
|
986
|
+
mentions: stringifyJson([{ memberId: members[0].id, label: 'Member 1' }]),
|
|
987
|
+
workRequestIds: stringifyJson([]),
|
|
988
|
+
artifactRefs: stringifyJson([]),
|
|
989
|
+
attachmentIds: stringifyJson([attachment.id]),
|
|
990
|
+
},
|
|
991
|
+
});
|
|
992
|
+
await createWorkRequest({
|
|
993
|
+
teamRunId: teamRun.id,
|
|
994
|
+
targetMemberId: members[0].id,
|
|
995
|
+
instruction,
|
|
996
|
+
triggerMessageId: message.id,
|
|
997
|
+
});
|
|
998
|
+
const workspaceService = createWorkspaceServiceMock();
|
|
999
|
+
const sessionManager = createSessionManagerMock();
|
|
1000
|
+
service = new TeamSchedulerService(lockService, {
|
|
1001
|
+
workspaceService,
|
|
1002
|
+
sessionManager,
|
|
1003
|
+
getProviderById: createProviderLookup(),
|
|
1004
|
+
});
|
|
1005
|
+
await service.startNextSessions(teamRun.id);
|
|
1006
|
+
expect(workspaceService.create).toHaveBeenCalledWith(task.id);
|
|
1007
|
+
expect(sessionManager.create).toHaveBeenCalledWith(expect.any(String), AgentType.CODEX, `Role 1\n\nTask:\n${instruction}`, 'DEFAULT', members[0].providerId);
|
|
1008
|
+
expect(sessionManager.create.mock.calls[0]?.[2]).not.toContain('Attachments:');
|
|
1009
|
+
});
|
|
705
1010
|
it('starts resume_last members with executor resume context while keeping a new Tower session and invocation', async () => {
|
|
706
1011
|
const { workspace, teamRun, members } = await createTeamRunFixture({
|
|
707
1012
|
sessionPolicies: ['resume_last'],
|
|
@@ -766,6 +1071,202 @@ describe('TeamSchedulerService', () => {
|
|
|
766
1071
|
status: 'RUNNING',
|
|
767
1072
|
});
|
|
768
1073
|
});
|
|
1074
|
+
it('starts new_per_request members without resuming previous native context', async () => {
|
|
1075
|
+
const { workspace, teamRun, members } = await createTeamRunFixture({
|
|
1076
|
+
sessionPolicies: ['new_per_request'],
|
|
1077
|
+
});
|
|
1078
|
+
const previousRequest = await createWorkRequest({
|
|
1079
|
+
teamRunId: teamRun.id,
|
|
1080
|
+
targetMemberId: members[0].id,
|
|
1081
|
+
status: 'STARTED',
|
|
1082
|
+
instruction: 'Previous work',
|
|
1083
|
+
});
|
|
1084
|
+
const previousSession = await prisma.session.create({
|
|
1085
|
+
data: {
|
|
1086
|
+
workspaceId: workspace.id,
|
|
1087
|
+
agentType: AgentType.CODEX,
|
|
1088
|
+
providerId: members[0].providerId,
|
|
1089
|
+
prompt: 'previous prompt',
|
|
1090
|
+
status: 'COMPLETED',
|
|
1091
|
+
logSnapshot: JSON.stringify({ sessionId: 'agent-native-session-1', entries: [] }),
|
|
1092
|
+
},
|
|
1093
|
+
});
|
|
1094
|
+
await prisma.agentInvocation.create({
|
|
1095
|
+
data: {
|
|
1096
|
+
teamRunId: teamRun.id,
|
|
1097
|
+
workRequestId: previousRequest.id,
|
|
1098
|
+
memberId: members[0].id,
|
|
1099
|
+
workspaceId: workspace.id,
|
|
1100
|
+
sessionId: previousSession.id,
|
|
1101
|
+
status: 'COMPLETED',
|
|
1102
|
+
},
|
|
1103
|
+
});
|
|
1104
|
+
const nextRequest = await createWorkRequest({
|
|
1105
|
+
teamRunId: teamRun.id,
|
|
1106
|
+
targetMemberId: members[0].id,
|
|
1107
|
+
instruction: 'Fresh request',
|
|
1108
|
+
});
|
|
1109
|
+
const sessionManager = createSessionManagerMock();
|
|
1110
|
+
service = new TeamSchedulerService(lockService, {
|
|
1111
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1112
|
+
sessionManager,
|
|
1113
|
+
getProviderById: createProviderLookup(),
|
|
1114
|
+
});
|
|
1115
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
1116
|
+
expect(invocations).toHaveLength(1);
|
|
1117
|
+
expect(invocations[0]).toMatchObject({
|
|
1118
|
+
workRequestId: nextRequest.id,
|
|
1119
|
+
memberId: members[0].id,
|
|
1120
|
+
sessionId: expect.any(String),
|
|
1121
|
+
status: 'RUNNING',
|
|
1122
|
+
});
|
|
1123
|
+
expect(invocations[0].sessionId).not.toBe(previousSession.id);
|
|
1124
|
+
expect(sessionManager.start).toHaveBeenCalledWith(invocations[0].sessionId);
|
|
1125
|
+
expect(sessionManager.startFollowUp).not.toHaveBeenCalled();
|
|
1126
|
+
});
|
|
1127
|
+
it('does not resume_last from a different member even when native context exists in the same workspace', async () => {
|
|
1128
|
+
const { workspace, teamRun, members } = await createTeamRunFixture({
|
|
1129
|
+
memberCapabilities: [readOnlyCapabilities, readOnlyCapabilities],
|
|
1130
|
+
sessionPolicies: ['resume_last', 'resume_last'],
|
|
1131
|
+
});
|
|
1132
|
+
const otherMemberRequest = await createWorkRequest({
|
|
1133
|
+
teamRunId: teamRun.id,
|
|
1134
|
+
targetMemberId: members[1].id,
|
|
1135
|
+
status: 'STARTED',
|
|
1136
|
+
instruction: 'Other member previous work',
|
|
1137
|
+
});
|
|
1138
|
+
const otherMemberSession = await prisma.session.create({
|
|
1139
|
+
data: {
|
|
1140
|
+
workspaceId: workspace.id,
|
|
1141
|
+
agentType: AgentType.CODEX,
|
|
1142
|
+
providerId: members[1].providerId,
|
|
1143
|
+
prompt: 'other member prompt',
|
|
1144
|
+
status: 'COMPLETED',
|
|
1145
|
+
logSnapshot: JSON.stringify({ sessionId: 'agent-native-session-2', entries: [] }),
|
|
1146
|
+
},
|
|
1147
|
+
});
|
|
1148
|
+
await prisma.agentInvocation.create({
|
|
1149
|
+
data: {
|
|
1150
|
+
teamRunId: teamRun.id,
|
|
1151
|
+
workRequestId: otherMemberRequest.id,
|
|
1152
|
+
memberId: members[1].id,
|
|
1153
|
+
workspaceId: workspace.id,
|
|
1154
|
+
sessionId: otherMemberSession.id,
|
|
1155
|
+
status: 'COMPLETED',
|
|
1156
|
+
},
|
|
1157
|
+
});
|
|
1158
|
+
const nextRequest = await createWorkRequest({
|
|
1159
|
+
teamRunId: teamRun.id,
|
|
1160
|
+
targetMemberId: members[0].id,
|
|
1161
|
+
instruction: 'Member 1 fresh work',
|
|
1162
|
+
});
|
|
1163
|
+
const sessionManager = createSessionManagerMock();
|
|
1164
|
+
service = new TeamSchedulerService(lockService, {
|
|
1165
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1166
|
+
sessionManager,
|
|
1167
|
+
getProviderById: createProviderLookup(),
|
|
1168
|
+
});
|
|
1169
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
1170
|
+
expect(invocations).toHaveLength(1);
|
|
1171
|
+
expect(invocations[0]).toMatchObject({
|
|
1172
|
+
workRequestId: nextRequest.id,
|
|
1173
|
+
memberId: members[0].id,
|
|
1174
|
+
status: 'RUNNING',
|
|
1175
|
+
});
|
|
1176
|
+
expect(sessionManager.start).toHaveBeenCalledWith(invocations[0].sessionId);
|
|
1177
|
+
expect(sessionManager.startFollowUp).not.toHaveBeenCalled();
|
|
1178
|
+
});
|
|
1179
|
+
it('resume_last only resumes native context from the same member in the selected workspace', async () => {
|
|
1180
|
+
const { task, workspace: selectedWorkspace, teamRun, members } = await createTeamRunFixture({
|
|
1181
|
+
sessionPolicies: ['resume_last'],
|
|
1182
|
+
});
|
|
1183
|
+
const otherWorkspace = await prisma.workspace.create({
|
|
1184
|
+
data: {
|
|
1185
|
+
taskId: task.id,
|
|
1186
|
+
parentWorkspaceId: selectedWorkspace.id,
|
|
1187
|
+
branchName: 'other-workspace',
|
|
1188
|
+
worktreePath: path.join(testDir, 'other-workspace'),
|
|
1189
|
+
status: 'ACTIVE',
|
|
1190
|
+
},
|
|
1191
|
+
});
|
|
1192
|
+
const previousRequest = await createWorkRequest({
|
|
1193
|
+
teamRunId: teamRun.id,
|
|
1194
|
+
targetMemberId: members[0].id,
|
|
1195
|
+
status: 'STARTED',
|
|
1196
|
+
instruction: 'Previous work in another workspace',
|
|
1197
|
+
});
|
|
1198
|
+
const otherWorkspaceSession = await prisma.session.create({
|
|
1199
|
+
data: {
|
|
1200
|
+
workspaceId: otherWorkspace.id,
|
|
1201
|
+
agentType: AgentType.CODEX,
|
|
1202
|
+
providerId: members[0].providerId,
|
|
1203
|
+
prompt: 'previous prompt in other workspace',
|
|
1204
|
+
status: 'COMPLETED',
|
|
1205
|
+
logSnapshot: JSON.stringify({ sessionId: 'agent-native-session-other-workspace', entries: [] }),
|
|
1206
|
+
createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
1207
|
+
updatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
1208
|
+
},
|
|
1209
|
+
});
|
|
1210
|
+
await prisma.agentInvocation.create({
|
|
1211
|
+
data: {
|
|
1212
|
+
teamRunId: teamRun.id,
|
|
1213
|
+
workRequestId: previousRequest.id,
|
|
1214
|
+
memberId: members[0].id,
|
|
1215
|
+
workspaceId: otherWorkspace.id,
|
|
1216
|
+
sessionId: otherWorkspaceSession.id,
|
|
1217
|
+
status: 'COMPLETED',
|
|
1218
|
+
createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
1219
|
+
updatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
|
|
1220
|
+
},
|
|
1221
|
+
});
|
|
1222
|
+
const nextRequest = await createWorkRequest({
|
|
1223
|
+
teamRunId: teamRun.id,
|
|
1224
|
+
targetMemberId: members[0].id,
|
|
1225
|
+
instruction: 'Continue in selected workspace',
|
|
1226
|
+
});
|
|
1227
|
+
const sessionManager = createSessionManagerMock();
|
|
1228
|
+
service = new TeamSchedulerService(lockService, {
|
|
1229
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1230
|
+
sessionManager,
|
|
1231
|
+
getProviderById: createProviderLookup(),
|
|
1232
|
+
});
|
|
1233
|
+
const firstRun = await service.startNextSessions(teamRun.id);
|
|
1234
|
+
expect(firstRun).toHaveLength(1);
|
|
1235
|
+
expect(firstRun[0]).toMatchObject({
|
|
1236
|
+
workRequestId: nextRequest.id,
|
|
1237
|
+
workspaceId: selectedWorkspace.id,
|
|
1238
|
+
status: 'RUNNING',
|
|
1239
|
+
});
|
|
1240
|
+
expect(sessionManager.start).toHaveBeenCalledWith(firstRun[0].sessionId);
|
|
1241
|
+
expect(sessionManager.startFollowUp).not.toHaveBeenCalled();
|
|
1242
|
+
await prisma.session.update({
|
|
1243
|
+
where: { id: firstRun[0].sessionId },
|
|
1244
|
+
data: {
|
|
1245
|
+
status: 'COMPLETED',
|
|
1246
|
+
logSnapshot: JSON.stringify({ sessionId: 'agent-native-session-selected-workspace', entries: [] }),
|
|
1247
|
+
},
|
|
1248
|
+
});
|
|
1249
|
+
await prisma.agentInvocation.update({
|
|
1250
|
+
where: { id: firstRun[0].id },
|
|
1251
|
+
data: { status: 'COMPLETED' },
|
|
1252
|
+
});
|
|
1253
|
+
const followUpRequest = await createWorkRequest({
|
|
1254
|
+
teamRunId: teamRun.id,
|
|
1255
|
+
targetMemberId: members[0].id,
|
|
1256
|
+
instruction: 'Continue again in selected workspace',
|
|
1257
|
+
});
|
|
1258
|
+
sessionManager.start.mockClear();
|
|
1259
|
+
sessionManager.startFollowUp.mockClear();
|
|
1260
|
+
const secondRun = await service.startNextSessions(teamRun.id);
|
|
1261
|
+
expect(secondRun).toHaveLength(1);
|
|
1262
|
+
expect(secondRun[0]).toMatchObject({
|
|
1263
|
+
workRequestId: followUpRequest.id,
|
|
1264
|
+
workspaceId: selectedWorkspace.id,
|
|
1265
|
+
status: 'RUNNING',
|
|
1266
|
+
});
|
|
1267
|
+
expect(sessionManager.startFollowUp).toHaveBeenCalledWith(secondRun[0].sessionId, firstRun[0].sessionId);
|
|
1268
|
+
expect(sessionManager.start).not.toHaveBeenCalled();
|
|
1269
|
+
});
|
|
769
1270
|
it('falls back to a normal session start for resume_last members without previous native context', async () => {
|
|
770
1271
|
const { teamRun, members } = await createTeamRunFixture({
|
|
771
1272
|
sessionPolicies: ['resume_last'],
|
|
@@ -998,6 +1499,73 @@ describe('TeamSchedulerService', () => {
|
|
|
998
1499
|
status: 'QUEUED',
|
|
999
1500
|
});
|
|
1000
1501
|
});
|
|
1502
|
+
it('starts none-policy write and command members on the shared workspace without workspace locks', async () => {
|
|
1503
|
+
const { workspace, teamRun, members } = await createTeamRunFixture({
|
|
1504
|
+
memberCapabilities: [
|
|
1505
|
+
{ ...writeCapabilities, runCommands: true },
|
|
1506
|
+
writeCapabilities,
|
|
1507
|
+
],
|
|
1508
|
+
workspacePolicies: ['none', 'shared'],
|
|
1509
|
+
});
|
|
1510
|
+
const noneRequest = await createWorkRequest({
|
|
1511
|
+
teamRunId: teamRun.id,
|
|
1512
|
+
targetMemberId: members[0].id,
|
|
1513
|
+
});
|
|
1514
|
+
const sharedRequest = await createWorkRequest({
|
|
1515
|
+
teamRunId: teamRun.id,
|
|
1516
|
+
targetMemberId: members[1].id,
|
|
1517
|
+
});
|
|
1518
|
+
const sessionManager = createSessionManagerMock();
|
|
1519
|
+
service = new TeamSchedulerService(lockService, {
|
|
1520
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1521
|
+
sessionManager,
|
|
1522
|
+
getProviderById: createProviderLookup(),
|
|
1523
|
+
});
|
|
1524
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
1525
|
+
expect(invocations).toHaveLength(2);
|
|
1526
|
+
expect(invocations.map((invocation) => invocation.workspaceId)).toEqual([workspace.id, workspace.id]);
|
|
1527
|
+
expect(new Set(invocations.map((invocation) => invocation.workRequestId))).toEqual(new Set([
|
|
1528
|
+
noneRequest.id,
|
|
1529
|
+
sharedRequest.id,
|
|
1530
|
+
]));
|
|
1531
|
+
expect(lockService.listLocks()).toEqual([
|
|
1532
|
+
{ key: `workspace:task:${teamRun.taskId}:write`, ownerId: invocations[1].id },
|
|
1533
|
+
]);
|
|
1534
|
+
});
|
|
1535
|
+
it('keeps shared command locks on the stable task key after creating a real workspace', async () => {
|
|
1536
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
1537
|
+
memberCapabilities: [commandCapabilities, commandCapabilities],
|
|
1538
|
+
withWorkspace: false,
|
|
1539
|
+
});
|
|
1540
|
+
const first = await createWorkRequest({
|
|
1541
|
+
teamRunId: teamRun.id,
|
|
1542
|
+
targetMemberId: members[0].id,
|
|
1543
|
+
});
|
|
1544
|
+
const second = await createWorkRequest({
|
|
1545
|
+
teamRunId: teamRun.id,
|
|
1546
|
+
targetMemberId: members[1].id,
|
|
1547
|
+
});
|
|
1548
|
+
service = new TeamSchedulerService(lockService, {
|
|
1549
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1550
|
+
sessionManager: createSessionManagerMock(),
|
|
1551
|
+
getProviderById: createProviderLookup(),
|
|
1552
|
+
});
|
|
1553
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
1554
|
+
expect(invocations).toHaveLength(1);
|
|
1555
|
+
expect(invocations[0]).toMatchObject({
|
|
1556
|
+
workRequestId: first.id,
|
|
1557
|
+
workspaceId: expect.any(String),
|
|
1558
|
+
status: 'RUNNING',
|
|
1559
|
+
});
|
|
1560
|
+
expect(lockService.listLocks()).toEqual([
|
|
1561
|
+
{ key: `workspace:task:${task.id}:command`, ownerId: invocations[0].id },
|
|
1562
|
+
]);
|
|
1563
|
+
await expect(prisma.workspace.count({ where: { taskId: task.id, status: 'ACTIVE' } })).resolves.toBe(1);
|
|
1564
|
+
await expect(prisma.workRequest.findUnique({ where: { id: second.id } })).resolves.toMatchObject({
|
|
1565
|
+
status: 'QUEUED',
|
|
1566
|
+
});
|
|
1567
|
+
await expect(service.startNextSessions(teamRun.id)).resolves.toEqual([]);
|
|
1568
|
+
});
|
|
1001
1569
|
it('keeps shared writer locks on the stable task key after creating a real workspace', async () => {
|
|
1002
1570
|
const { task, teamRun, members } = await createTeamRunFixture({
|
|
1003
1571
|
memberCapabilities: [writeCapabilities, writeCapabilities],
|
|
@@ -1092,7 +1660,7 @@ describe('TeamSchedulerService', () => {
|
|
|
1092
1660
|
expect(new Set(sessions.map((session) => session.workspaceId))).toEqual(new Set([workspaceIds[0]]));
|
|
1093
1661
|
expect(lockService.listLocks()).toEqual([]);
|
|
1094
1662
|
});
|
|
1095
|
-
it('
|
|
1663
|
+
it('marks a request failed and leaves no session or lock when a provider is missing', async () => {
|
|
1096
1664
|
const { teamRun, members } = await createTeamRunFixture({
|
|
1097
1665
|
memberCapabilities: [writeCapabilities],
|
|
1098
1666
|
withWorkspace: false,
|
|
@@ -1108,18 +1676,104 @@ describe('TeamSchedulerService', () => {
|
|
|
1108
1676
|
sessionManager,
|
|
1109
1677
|
getProviderById: vi.fn(() => null),
|
|
1110
1678
|
});
|
|
1111
|
-
await expect(service.startNextSessions(teamRun.id)).
|
|
1112
|
-
code: 'PROVIDER_NOT_FOUND',
|
|
1113
|
-
message: `Provider not found: ${members[0].providerId}`,
|
|
1114
|
-
});
|
|
1679
|
+
await expect(service.startNextSessions(teamRun.id)).resolves.toEqual([]);
|
|
1115
1680
|
expect(workspaceService.create).not.toHaveBeenCalled();
|
|
1116
1681
|
expect(sessionManager.create).not.toHaveBeenCalled();
|
|
1117
1682
|
expect(lockService.listLocks()).toEqual([]);
|
|
1118
1683
|
await expect(prisma.session.count()).resolves.toBe(0);
|
|
1119
|
-
await expect(prisma.agentInvocation.
|
|
1684
|
+
await expect(prisma.agentInvocation.findFirst({ where: { workRequestId: request.id } })).resolves.toMatchObject({
|
|
1685
|
+
memberId: members[0].id,
|
|
1686
|
+
sessionId: null,
|
|
1687
|
+
status: 'FAILED',
|
|
1688
|
+
});
|
|
1120
1689
|
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
1121
|
-
status: '
|
|
1690
|
+
status: 'STARTED',
|
|
1691
|
+
});
|
|
1692
|
+
await expect(service.startNextSessions(teamRun.id)).resolves.toEqual([]);
|
|
1693
|
+
});
|
|
1694
|
+
it('advances an idle TeamRun to review when provider missing failures consume all queued work', async () => {
|
|
1695
|
+
const { task, teamRun, members } = await createTeamRunFixture({
|
|
1696
|
+
memberCapabilities: [writeCapabilities],
|
|
1697
|
+
withWorkspace: false,
|
|
1698
|
+
taskStatus: TaskStatus.IN_PROGRESS,
|
|
1699
|
+
});
|
|
1700
|
+
const request = await createWorkRequest({
|
|
1701
|
+
teamRunId: teamRun.id,
|
|
1702
|
+
targetMemberId: members[0].id,
|
|
1703
|
+
});
|
|
1704
|
+
service = new TeamSchedulerService(lockService, {
|
|
1705
|
+
workspaceService: createWorkspaceServiceMock(),
|
|
1706
|
+
sessionManager: createSessionManagerMock(),
|
|
1707
|
+
getProviderById: vi.fn(() => null),
|
|
1708
|
+
});
|
|
1709
|
+
await expect(service.startNextSessions(teamRun.id)).resolves.toEqual([]);
|
|
1710
|
+
await expect(prisma.workRequest.findUnique({ where: { id: request.id } })).resolves.toMatchObject({
|
|
1711
|
+
status: 'STARTED',
|
|
1712
|
+
});
|
|
1713
|
+
await expect(prisma.agentInvocation.findFirst({ where: { workRequestId: request.id } })).resolves.toMatchObject({
|
|
1714
|
+
status: 'FAILED',
|
|
1715
|
+
sessionId: null,
|
|
1716
|
+
});
|
|
1717
|
+
await expect(prisma.task.findUnique({ where: { id: task.id } })).resolves.toMatchObject({
|
|
1718
|
+
status: TaskStatus.IN_REVIEW,
|
|
1122
1719
|
});
|
|
1720
|
+
await expect(prisma.teamRun.findUnique({ where: { id: teamRun.id } })).resolves.toMatchObject({
|
|
1721
|
+
reviewReason: 'TEAM_QUIESCENT',
|
|
1722
|
+
});
|
|
1723
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
1724
|
+
});
|
|
1725
|
+
it('continues starting later queued work when an earlier request has a missing provider', async () => {
|
|
1726
|
+
const { teamRun, members } = await createTeamRunFixture({
|
|
1727
|
+
memberCapabilities: [writeCapabilities, readOnlyCapabilities],
|
|
1728
|
+
withWorkspace: false,
|
|
1729
|
+
});
|
|
1730
|
+
const missingProviderRequest = await createWorkRequest({
|
|
1731
|
+
teamRunId: teamRun.id,
|
|
1732
|
+
targetMemberId: members[0].id,
|
|
1733
|
+
});
|
|
1734
|
+
const validProviderRequest = await createWorkRequest({
|
|
1735
|
+
teamRunId: teamRun.id,
|
|
1736
|
+
targetMemberId: members[1].id,
|
|
1737
|
+
});
|
|
1738
|
+
const workspaceService = createWorkspaceServiceMock();
|
|
1739
|
+
const sessionManager = createSessionManagerMock();
|
|
1740
|
+
service = new TeamSchedulerService(lockService, {
|
|
1741
|
+
workspaceService,
|
|
1742
|
+
sessionManager,
|
|
1743
|
+
getProviderById: vi.fn((providerId) => (providerId === members[0].providerId
|
|
1744
|
+
? null
|
|
1745
|
+
: {
|
|
1746
|
+
id: providerId,
|
|
1747
|
+
name: providerId,
|
|
1748
|
+
agentType: AgentType.CODEX,
|
|
1749
|
+
env: {},
|
|
1750
|
+
config: {},
|
|
1751
|
+
isDefault: false,
|
|
1752
|
+
})),
|
|
1753
|
+
});
|
|
1754
|
+
const invocations = await service.startNextSessions(teamRun.id);
|
|
1755
|
+
expect(invocations).toHaveLength(1);
|
|
1756
|
+
expect(invocations[0]).toMatchObject({
|
|
1757
|
+
workRequestId: validProviderRequest.id,
|
|
1758
|
+
memberId: members[1].id,
|
|
1759
|
+
status: 'RUNNING',
|
|
1760
|
+
sessionId: expect.any(String),
|
|
1761
|
+
});
|
|
1762
|
+
await expect(prisma.workRequest.findUnique({ where: { id: missingProviderRequest.id } })).resolves.toMatchObject({
|
|
1763
|
+
status: 'STARTED',
|
|
1764
|
+
});
|
|
1765
|
+
await expect(prisma.agentInvocation.findFirst({
|
|
1766
|
+
where: { workRequestId: missingProviderRequest.id },
|
|
1767
|
+
})).resolves.toMatchObject({
|
|
1768
|
+
memberId: members[0].id,
|
|
1769
|
+
sessionId: null,
|
|
1770
|
+
status: 'FAILED',
|
|
1771
|
+
});
|
|
1772
|
+
await expect(prisma.workRequest.findUnique({ where: { id: validProviderRequest.id } })).resolves.toMatchObject({
|
|
1773
|
+
status: 'STARTED',
|
|
1774
|
+
});
|
|
1775
|
+
expect(sessionManager.create).toHaveBeenCalledTimes(1);
|
|
1776
|
+
expect(lockService.listLocks()).toEqual([]);
|
|
1123
1777
|
});
|
|
1124
1778
|
it('marks invocation and session failed and releases locks when session start fails', async () => {
|
|
1125
1779
|
const { teamRun, members } = await createTeamRunFixture({
|