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.
Files changed (216) hide show
  1. package/dist/app.test.js +2 -0
  2. package/dist/app.test.js.map +1 -1
  3. package/dist/git/git-cli.d.ts +18 -1
  4. package/dist/git/git-cli.d.ts.map +1 -1
  5. package/dist/git/git-cli.js +17 -1
  6. package/dist/git/git-cli.js.map +1 -1
  7. package/dist/git/worktree.manager.d.ts +29 -2
  8. package/dist/git/worktree.manager.d.ts.map +1 -1
  9. package/dist/git/worktree.manager.js +137 -16
  10. package/dist/git/worktree.manager.js.map +1 -1
  11. package/dist/git/worktree.manager.test.d.ts +2 -0
  12. package/dist/git/worktree.manager.test.d.ts.map +1 -0
  13. package/dist/git/worktree.manager.test.js +104 -0
  14. package/dist/git/worktree.manager.test.js.map +1 -0
  15. package/dist/mcp/http-client.d.ts +5 -1
  16. package/dist/mcp/http-client.d.ts.map +1 -1
  17. package/dist/mcp/http-client.js +13 -3
  18. package/dist/mcp/http-client.js.map +1 -1
  19. package/dist/mcp/server.d.ts.map +1 -1
  20. package/dist/mcp/server.js +33 -3
  21. package/dist/mcp/server.js.map +1 -1
  22. package/dist/middleware/tunnel-auth.d.ts.map +1 -1
  23. package/dist/middleware/tunnel-auth.js +2 -0
  24. package/dist/middleware/tunnel-auth.js.map +1 -1
  25. package/dist/output/__tests__/codex-parser.test.d.ts +2 -0
  26. package/dist/output/__tests__/codex-parser.test.d.ts.map +1 -0
  27. package/dist/output/__tests__/codex-parser.test.js +148 -0
  28. package/dist/output/__tests__/codex-parser.test.js.map +1 -0
  29. package/dist/output/codex-parser.d.ts +12 -0
  30. package/dist/output/codex-parser.d.ts.map +1 -1
  31. package/dist/output/codex-parser.js +129 -12
  32. package/dist/output/codex-parser.js.map +1 -1
  33. package/dist/routes/__tests__/attachments.test.d.ts +2 -0
  34. package/dist/routes/__tests__/attachments.test.d.ts.map +1 -0
  35. package/dist/routes/__tests__/attachments.test.js +86 -0
  36. package/dist/routes/__tests__/attachments.test.js.map +1 -0
  37. package/dist/routes/__tests__/filesystem.test.d.ts +2 -0
  38. package/dist/routes/__tests__/filesystem.test.d.ts.map +1 -0
  39. package/dist/routes/__tests__/filesystem.test.js +80 -0
  40. package/dist/routes/__tests__/filesystem.test.js.map +1 -0
  41. package/dist/routes/__tests__/previews.test.d.ts +2 -0
  42. package/dist/routes/__tests__/previews.test.d.ts.map +1 -0
  43. package/dist/routes/__tests__/previews.test.js +89 -0
  44. package/dist/routes/__tests__/previews.test.js.map +1 -0
  45. package/dist/routes/__tests__/tasks.test.d.ts +2 -0
  46. package/dist/routes/__tests__/tasks.test.d.ts.map +1 -0
  47. package/dist/routes/__tests__/tasks.test.js +72 -0
  48. package/dist/routes/__tests__/tasks.test.js.map +1 -0
  49. package/dist/routes/attachments.d.ts.map +1 -1
  50. package/dist/routes/attachments.js +36 -16
  51. package/dist/routes/attachments.js.map +1 -1
  52. package/dist/routes/filesystem.d.ts.map +1 -1
  53. package/dist/routes/filesystem.js +24 -3
  54. package/dist/routes/filesystem.js.map +1 -1
  55. package/dist/routes/index.d.ts.map +1 -1
  56. package/dist/routes/index.js +3 -0
  57. package/dist/routes/index.js.map +1 -1
  58. package/dist/routes/previews.d.ts +6 -0
  59. package/dist/routes/previews.d.ts.map +1 -0
  60. package/dist/routes/previews.js +413 -0
  61. package/dist/routes/previews.js.map +1 -0
  62. package/dist/routes/projects.d.ts.map +1 -1
  63. package/dist/routes/projects.js +1 -0
  64. package/dist/routes/projects.js.map +1 -1
  65. package/dist/routes/tasks.js +2 -2
  66. package/dist/routes/tasks.js.map +1 -1
  67. package/dist/routes/team-runs.d.ts.map +1 -1
  68. package/dist/routes/team-runs.js +36 -9
  69. package/dist/routes/team-runs.js.map +1 -1
  70. package/dist/routes/tunnel.d.ts.map +1 -1
  71. package/dist/routes/tunnel.js +20 -0
  72. package/dist/routes/tunnel.js.map +1 -1
  73. package/dist/routes/workspaces.d.ts.map +1 -1
  74. package/dist/routes/workspaces.js +15 -1
  75. package/dist/routes/workspaces.js.map +1 -1
  76. package/dist/services/__tests__/preview.service.test.d.ts +2 -0
  77. package/dist/services/__tests__/preview.service.test.d.ts.map +1 -0
  78. package/dist/services/__tests__/preview.service.test.js +29 -0
  79. package/dist/services/__tests__/preview.service.test.js.map +1 -0
  80. package/dist/services/__tests__/task.service.test.d.ts +2 -0
  81. package/dist/services/__tests__/task.service.test.d.ts.map +1 -0
  82. package/dist/services/__tests__/task.service.test.js +65 -0
  83. package/dist/services/__tests__/task.service.test.js.map +1 -0
  84. package/dist/services/__tests__/team-reconciler.service.test.js +720 -28
  85. package/dist/services/__tests__/team-reconciler.service.test.js.map +1 -1
  86. package/dist/services/__tests__/team-run.service.test.js +416 -0
  87. package/dist/services/__tests__/team-run.service.test.js.map +1 -1
  88. package/dist/services/__tests__/team-scheduler.service.test.js +680 -26
  89. package/dist/services/__tests__/team-scheduler.service.test.js.map +1 -1
  90. package/dist/services/__tests__/tunnel.service.test.d.ts +2 -0
  91. package/dist/services/__tests__/tunnel.service.test.d.ts.map +1 -0
  92. package/dist/services/__tests__/tunnel.service.test.js +138 -0
  93. package/dist/services/__tests__/tunnel.service.test.js.map +1 -0
  94. package/dist/services/__tests__/workspace.service.test.d.ts +2 -0
  95. package/dist/services/__tests__/workspace.service.test.d.ts.map +1 -0
  96. package/dist/services/__tests__/workspace.service.test.js +695 -0
  97. package/dist/services/__tests__/workspace.service.test.js.map +1 -0
  98. package/dist/services/attachment-context.d.ts +3 -0
  99. package/dist/services/attachment-context.d.ts.map +1 -0
  100. package/dist/services/attachment-context.js +34 -0
  101. package/dist/services/attachment-context.js.map +1 -0
  102. package/dist/services/preview.service.d.ts +19 -0
  103. package/dist/services/preview.service.d.ts.map +1 -0
  104. package/dist/services/preview.service.js +147 -0
  105. package/dist/services/preview.service.js.map +1 -0
  106. package/dist/services/project.service.d.ts +2 -0
  107. package/dist/services/project.service.d.ts.map +1 -1
  108. package/dist/services/project.service.js +87 -18
  109. package/dist/services/project.service.js.map +1 -1
  110. package/dist/services/session-manager.d.ts +12 -0
  111. package/dist/services/session-manager.d.ts.map +1 -1
  112. package/dist/services/task.service.d.ts +6 -0
  113. package/dist/services/task.service.d.ts.map +1 -1
  114. package/dist/services/task.service.js +15 -3
  115. package/dist/services/task.service.js.map +1 -1
  116. package/dist/services/team-lock.service.d.ts +3 -0
  117. package/dist/services/team-lock.service.d.ts.map +1 -1
  118. package/dist/services/team-lock.service.js +11 -0
  119. package/dist/services/team-lock.service.js.map +1 -1
  120. package/dist/services/team-run.service.d.ts +34 -1
  121. package/dist/services/team-run.service.d.ts.map +1 -1
  122. package/dist/services/team-run.service.js +370 -30
  123. package/dist/services/team-run.service.js.map +1 -1
  124. package/dist/services/team-scheduler.service.d.ts +22 -1
  125. package/dist/services/team-scheduler.service.d.ts.map +1 -1
  126. package/dist/services/team-scheduler.service.js +148 -33
  127. package/dist/services/team-scheduler.service.js.map +1 -1
  128. package/dist/services/tunnel.service.d.ts +31 -5
  129. package/dist/services/tunnel.service.d.ts.map +1 -1
  130. package/dist/services/tunnel.service.js +293 -32
  131. package/dist/services/tunnel.service.js.map +1 -1
  132. package/dist/services/workspace.service.d.ts +161 -7
  133. package/dist/services/workspace.service.d.ts.map +1 -1
  134. package/dist/services/workspace.service.js +396 -51
  135. package/dist/services/workspace.service.js.map +1 -1
  136. package/dist/web/assets/{AgentDemoPage-p9YI4_l4.js → AgentDemoPage-BhDnxdmh.js} +1 -1
  137. package/dist/web/assets/{DemoPage-B5DTSEbS.js → DemoPage-CJBc0NZf.js} +1 -1
  138. package/dist/web/assets/{GeneralSettingsPage-Cspr7Vol.js → GeneralSettingsPage-CEjDPmtD.js} +1 -1
  139. package/dist/web/assets/MemberAvatar-DxRCLAoK.js +1 -0
  140. package/dist/web/assets/NotificationSettingsPage-i77lmSic.js +1 -0
  141. package/dist/web/assets/{ProfileSettingsPage-CNugU40a.js → ProfileSettingsPage-DcGLD5O-.js} +1 -1
  142. package/dist/web/assets/ProjectKanbanPage-CX-NY7hx.js +89 -0
  143. package/dist/web/assets/ProjectSettingsPage-BBfroQPA.js +2 -0
  144. package/dist/web/assets/{ProviderSettingsPage-D_KWkgRM.js → ProviderSettingsPage-BdxbO1E9.js} +12 -12
  145. package/dist/web/assets/TeamSettingsPage-CTWGO79W.js +1 -0
  146. package/dist/web/assets/agent-tower-logo-COx9gy77.png +0 -0
  147. package/dist/web/assets/{button-B6JaSbDB.js → button-RDdre_kF.js} +1 -1
  148. package/dist/web/assets/{chevron-down-CACy4UFq.js → chevron-down-Cfeapk0v.js} +1 -1
  149. package/dist/web/assets/{chevron-right-DFWfnDJY.js → chevron-right-BE6LVCii.js} +1 -1
  150. package/dist/web/assets/{chevron-up-CGlf6jzw.js → chevron-up-CEEz4jJv.js} +1 -1
  151. package/dist/web/assets/{circle-check-DMK8auwb.js → circle-check-0imI5gEL.js} +1 -1
  152. package/dist/web/assets/{code-block-OCS4YCEC-Hn75KHRK.js → code-block-OCS4YCEC-D4c9zDcq.js} +1 -1
  153. package/dist/web/assets/{confirm-dialog-DHI2f7Ni.js → confirm-dialog--HDqEa-R.js} +1 -1
  154. package/dist/web/assets/folder-picker-CKfogW-o.js +1 -0
  155. package/dist/web/assets/index-BHmOCKAn.css +1 -0
  156. package/dist/web/assets/{index-BFAA3PTl.js → index-DbGCpy8E.js} +9 -9
  157. package/dist/web/assets/loader-circle-BAUFMewp.js +1 -0
  158. package/dist/web/assets/{log-adapter-CeKrvZcz.js → log-adapter-DKKM3sxS.js} +1 -1
  159. package/dist/web/assets/{mermaid-NOHMQCX5-DJFgrXPd.js → mermaid-NOHMQCX5-BkaKG_2K.js} +4 -4
  160. package/dist/web/assets/{modal-B5IRN7QI.js → modal-3rdeMVPn.js} +1 -1
  161. package/dist/web/assets/{pencil-CJY6Ahn7.js → pencil-CqWv0WcO.js} +1 -1
  162. package/dist/web/assets/{select-BPZZlla1.js → select-DHVfUr22.js} +1 -1
  163. package/dist/web/assets/upload-D3aqtSCY.js +1 -0
  164. package/dist/web/assets/{use-profiles-C2k04ICZ.js → use-profiles-BznmWvqM.js} +1 -1
  165. package/dist/web/assets/{use-providers-C7fIDWzP.js → use-providers-DIHUIuEZ.js} +1 -1
  166. package/dist/web/avatars/presets/avatar-preset-01-developer.png +0 -0
  167. package/dist/web/avatars/presets/avatar-preset-02-architect.png +0 -0
  168. package/dist/web/avatars/presets/avatar-preset-03-tester.png +0 -0
  169. package/dist/web/avatars/presets/avatar-preset-04-devops.png +0 -0
  170. package/dist/web/avatars/presets/avatar-preset-05-data-scientist.png +0 -0
  171. package/dist/web/avatars/presets/avatar-preset-06-frontend.png +0 -0
  172. package/dist/web/avatars/presets/avatar-preset-07-backend.png +0 -0
  173. package/dist/web/avatars/presets/avatar-preset-08-security.png +0 -0
  174. package/dist/web/avatars/presets/avatar-preset-09-project-manager.png +0 -0
  175. package/dist/web/avatars/presets/avatar-preset-10-product-manager.png +0 -0
  176. package/dist/web/avatars/presets/avatar-preset-11-scrum-master.png +0 -0
  177. package/dist/web/avatars/presets/avatar-preset-12-tech-lead.png +0 -0
  178. package/dist/web/avatars/presets/avatar-preset-13-coordinator.png +0 -0
  179. package/dist/web/avatars/presets/avatar-preset-14-mentor.png +0 -0
  180. package/dist/web/avatars/presets/avatar-preset-15-reviewer.png +0 -0
  181. package/dist/web/avatars/presets/avatar-preset-16-ui-designer.png +0 -0
  182. package/dist/web/avatars/presets/avatar-preset-17-ux-researcher.png +0 -0
  183. package/dist/web/avatars/presets/avatar-preset-18-documenter.png +0 -0
  184. package/dist/web/avatars/presets/avatar-preset-19-translator.png +0 -0
  185. package/dist/web/avatars/presets/avatar-preset-20-analyst.png +0 -0
  186. package/dist/web/avatars/presets/avatar-preset-21-consultant.png +0 -0
  187. package/dist/web/avatars/presets/avatar-preset-22-creative-director.png +0 -0
  188. package/dist/web/avatars/presets/avatar-preset-23-support.png +0 -0
  189. package/dist/web/avatars/presets/avatar-preset-24-assistant.png +0 -0
  190. package/dist/web/avatars/presets/avatar-preset-25-robot.png +0 -0
  191. package/dist/web/avatars/presets/avatar-preset-grid.png +0 -0
  192. package/dist/web/index.html +2 -2
  193. package/node_modules/@agent-tower/shared/dist/types.d.ts +13 -1
  194. package/node_modules/@agent-tower/shared/dist/types.d.ts.map +1 -1
  195. package/node_modules/@agent-tower/shared/dist/types.js.map +1 -1
  196. package/node_modules/@prisma/client/.prisma/client/edge.js +11 -5
  197. package/node_modules/@prisma/client/.prisma/client/index-browser.js +6 -0
  198. package/node_modules/@prisma/client/.prisma/client/index.d.ts +1309 -14
  199. package/node_modules/@prisma/client/.prisma/client/index.js +11 -5
  200. package/node_modules/@prisma/client/.prisma/client/package.json +1 -1
  201. package/node_modules/@prisma/client/.prisma/client/schema.prisma +70 -52
  202. package/node_modules/@prisma/client/.prisma/client/wasm.js +6 -0
  203. package/package.json +1 -1
  204. package/prisma/migrations/20260515000000_add_workspace_preview_target/migration.sql +2 -0
  205. package/prisma/migrations/20260526000000_add_team_run_main_and_dedicated_workspaces/migration.sql +21 -0
  206. package/prisma/migrations/20260529000000_add_team_member_queue_management_policy/migration.sql +2 -0
  207. package/prisma/schema.prisma +29 -11
  208. package/dist/web/assets/NotificationSettingsPage-C9VfrRr-.js +0 -1
  209. package/dist/web/assets/ProjectKanbanPage-CkGNuqxq.js +0 -87
  210. package/dist/web/assets/ProjectSettingsPage-f1dg0XMf.js +0 -2
  211. package/dist/web/assets/TeamSettingsPage-B6WciZyi.js +0 -1
  212. package/dist/web/assets/circle-alert-BSAUEd9O.js +0 -1
  213. package/dist/web/assets/folder-picker-CtQkbWfa.js +0 -1
  214. package/dist/web/assets/index-mBCb67dB.css +0 -1
  215. package/dist/web/assets/loader-circle-CkDnf8ST.js +0 -1
  216. 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('cancels pending and queued WorkRequests', async () => {
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)).resolves.toMatchObject({ status: 'CANCELLED' });
315
- await expect(service.cancelWorkRequest(queued.id)).resolves.toMatchObject({ status: 'CANCELLED' });
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)).rejects.toMatchObject({
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)).rejects.toMatchObject({
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('leaves dedicated workspace members blocked because dedicated startup is reserved', async () => {
621
- const { teamRun, members } = await createTeamRunFixture({
622
- workspacePolicies: ['dedicated'],
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 request = await createWorkRequest({
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: request.id,
631
- canStart: false,
632
- blockedReason: 'unsupported_workspace_policy',
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 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({
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![reference.png](${attachment.storagePath})`, '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![reference.png](${attachment.storagePath})`;
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('fails clearly and leaves no session or lock when a provider is missing', async () => {
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)).rejects.toMatchObject({
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.count()).resolves.toBe(0);
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: 'QUEUED',
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({