agent-tower 0.5.3-beta.9 → 0.5.4-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/mcp/http-client.d.ts +31 -1
  2. package/dist/mcp/http-client.d.ts.map +1 -1
  3. package/dist/mcp/http-client.js +14 -2
  4. package/dist/mcp/http-client.js.map +1 -1
  5. package/dist/mcp/server.d.ts.map +1 -1
  6. package/dist/mcp/server.js +62 -8
  7. package/dist/mcp/server.js.map +1 -1
  8. package/dist/mcp/tools/__tests__/workspaces.test.d.ts +2 -0
  9. package/dist/mcp/tools/__tests__/workspaces.test.d.ts.map +1 -0
  10. package/dist/mcp/tools/__tests__/workspaces.test.js +72 -0
  11. package/dist/mcp/tools/__tests__/workspaces.test.js.map +1 -0
  12. package/dist/mcp/tools/workspaces.d.ts +9 -1
  13. package/dist/mcp/tools/workspaces.d.ts.map +1 -1
  14. package/dist/mcp/tools/workspaces.js +96 -2
  15. package/dist/mcp/tools/workspaces.js.map +1 -1
  16. package/dist/mcp/types.d.ts +46 -0
  17. package/dist/mcp/types.d.ts.map +1 -1
  18. package/dist/mcp/types.js +23 -0
  19. package/dist/mcp/types.js.map +1 -1
  20. package/dist/routes/__tests__/team-runs-merge.test.d.ts +2 -0
  21. package/dist/routes/__tests__/team-runs-merge.test.d.ts.map +1 -0
  22. package/dist/routes/__tests__/team-runs-merge.test.js +156 -0
  23. package/dist/routes/__tests__/team-runs-merge.test.js.map +1 -0
  24. package/dist/routes/__tests__/team-runs-private-messages.test.d.ts +2 -0
  25. package/dist/routes/__tests__/team-runs-private-messages.test.d.ts.map +1 -0
  26. package/dist/routes/__tests__/team-runs-private-messages.test.js +82 -0
  27. package/dist/routes/__tests__/team-runs-private-messages.test.js.map +1 -0
  28. package/dist/routes/team-runs.d.ts +2 -0
  29. package/dist/routes/team-runs.d.ts.map +1 -1
  30. package/dist/routes/team-runs.js +48 -1
  31. package/dist/routes/team-runs.js.map +1 -1
  32. package/dist/routes/workspaces.d.ts.map +1 -1
  33. package/dist/routes/workspaces.js +43 -4
  34. package/dist/routes/workspaces.js.map +1 -1
  35. package/dist/services/__tests__/session-manager.team-run.test.js +75 -0
  36. package/dist/services/__tests__/session-manager.team-run.test.js.map +1 -1
  37. package/dist/services/__tests__/team-reconciler.service.test.js +127 -8
  38. package/dist/services/__tests__/team-reconciler.service.test.js.map +1 -1
  39. package/dist/services/__tests__/team-run.service.test.js +172 -0
  40. package/dist/services/__tests__/team-run.service.test.js.map +1 -1
  41. package/dist/services/__tests__/team-scheduler.service.test.js +397 -0
  42. package/dist/services/__tests__/team-scheduler.service.test.js.map +1 -1
  43. package/dist/services/__tests__/workspace.service.test.js +959 -3
  44. package/dist/services/__tests__/workspace.service.test.js.map +1 -1
  45. package/dist/services/session-manager.d.ts.map +1 -1
  46. package/dist/services/session-manager.js +16 -0
  47. package/dist/services/session-manager.js.map +1 -1
  48. package/dist/services/team-run.service.d.ts +5 -1
  49. package/dist/services/team-run.service.d.ts.map +1 -1
  50. package/dist/services/team-run.service.js +63 -2
  51. package/dist/services/team-run.service.js.map +1 -1
  52. package/dist/services/team-scheduler.service.d.ts +21 -1
  53. package/dist/services/team-scheduler.service.d.ts.map +1 -1
  54. package/dist/services/team-scheduler.service.js +230 -8
  55. package/dist/services/team-scheduler.service.js.map +1 -1
  56. package/dist/services/workspace.service.d.ts +56 -1
  57. package/dist/services/workspace.service.d.ts.map +1 -1
  58. package/dist/services/workspace.service.js +837 -4
  59. package/dist/services/workspace.service.js.map +1 -1
  60. package/node_modules/@agent-tower/shared/dist/types.d.ts +168 -0
  61. package/node_modules/@agent-tower/shared/dist/types.d.ts.map +1 -1
  62. package/node_modules/@agent-tower/shared/dist/types.js.map +1 -1
  63. package/node_modules/@prisma/client/.prisma/client/edge.js +39 -5
  64. package/node_modules/@prisma/client/.prisma/client/index-browser.js +34 -0
  65. package/node_modules/@prisma/client/.prisma/client/index.d.ts +2871 -158
  66. package/node_modules/@prisma/client/.prisma/client/index.js +39 -5
  67. package/node_modules/@prisma/client/.prisma/client/package.json +1 -1
  68. package/node_modules/@prisma/client/.prisma/client/schema.prisma +81 -30
  69. package/node_modules/@prisma/client/.prisma/client/wasm.js +34 -0
  70. package/package.json +1 -1
  71. package/prisma/migrations/20260618010000_add_workspace_verdicts/migration.sql +27 -0
  72. package/prisma/migrations/20260702000000_add_teamrun_target_commit_fields/migration.sql +38 -0
  73. package/prisma/schema.prisma +51 -0
@@ -9,13 +9,14 @@ import { WorkspaceKind } from '../../types/index.js';
9
9
  const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-tower-workspace-service-'));
10
10
  const dbPath = path.join(testDir, 'test.db');
11
11
  process.env.AGENT_TOWER_DATABASE_URL = `file:${dbPath}`;
12
- const { createWorktreeMock, ensureWorktreeExistsMock, removeWorktreeMock, deleteBranchIfSafeMock, mergeWorktreeMock, mergeIntoWorktreeMock, execGitMock, refreshWorkspaceGitWatcherMock, unwatchWorkspaceGitWatcherMock, } = vi.hoisted(() => ({
12
+ const { createWorktreeMock, ensureWorktreeExistsMock, removeWorktreeMock, deleteBranchIfSafeMock, mergeWorktreeMock, mergeIntoWorktreeMock, getGitOperationStatusMock, execGitMock, refreshWorkspaceGitWatcherMock, unwatchWorkspaceGitWatcherMock, } = vi.hoisted(() => ({
13
13
  createWorktreeMock: vi.fn(),
14
14
  ensureWorktreeExistsMock: vi.fn(),
15
15
  removeWorktreeMock: vi.fn(),
16
16
  deleteBranchIfSafeMock: vi.fn(),
17
17
  mergeWorktreeMock: vi.fn(),
18
18
  mergeIntoWorktreeMock: vi.fn(),
19
+ getGitOperationStatusMock: vi.fn(),
19
20
  execGitMock: vi.fn(),
20
21
  refreshWorkspaceGitWatcherMock: vi.fn(async () => undefined),
21
22
  unwatchWorkspaceGitWatcherMock: vi.fn(),
@@ -29,7 +30,7 @@ vi.mock('../../git/worktree.manager.js', () => ({
29
30
  deleteBranchIfSafe: deleteBranchIfSafeMock,
30
31
  getDiff: vi.fn(),
31
32
  rebase: vi.fn(),
32
- getGitOperationStatus: vi.fn(),
33
+ getGitOperationStatus: getGitOperationStatusMock,
33
34
  abortOperation: vi.fn(),
34
35
  merge: mergeWorktreeMock,
35
36
  mergeIntoWorktree: mergeIntoWorktreeMock,
@@ -46,6 +47,9 @@ execGitMock.mockImplementation(async (_repoPath, args) => {
46
47
  if (args[0] === 'rev-parse' && args.includes('--abbrev-ref')) {
47
48
  return 'main\n';
48
49
  }
50
+ if (args[0] === 'rev-parse' && args[1] === 'HEAD') {
51
+ return 'child-head-sha\n';
52
+ }
49
53
  if (args[0] === 'status') {
50
54
  return '';
51
55
  }
@@ -118,6 +122,133 @@ async function createTeamRunWithMember(options = {}) {
118
122
  });
119
123
  return { project, task, teamRun, member };
120
124
  }
125
+ async function addTeamMember(teamRunId, overrides = {}) {
126
+ const suffix = Math.random().toString(36).slice(2, 8);
127
+ return prisma.teamMember.create({
128
+ data: {
129
+ teamRunId,
130
+ presetId: null,
131
+ name: overrides.name ?? `Member ${suffix}`,
132
+ aliases: JSON.stringify([`member-${suffix}`]),
133
+ providerId: `provider-${suffix}`,
134
+ rolePrompt: `Role ${suffix}`,
135
+ capabilities: JSON.stringify(overrides.capabilities ?? {}),
136
+ workspacePolicy: 'dedicated',
137
+ triggerPolicy: 'MENTION_ONLY',
138
+ sessionPolicy: 'new_per_request',
139
+ avatar: null,
140
+ },
141
+ });
142
+ }
143
+ async function createTeamRunChildMergeFixture(options = {}) {
144
+ const { task, teamRun, member: owner } = await createTeamRunWithMember();
145
+ await prisma.task.update({
146
+ where: { id: task.id },
147
+ data: { status: 'IN_REVIEW' },
148
+ });
149
+ const reviewer = options.reviewerIsOwner
150
+ ? owner
151
+ : await addTeamMember(teamRun.id, { name: 'Reviewer', capabilities: { readDiff: true } });
152
+ const merger = await addTeamMember(teamRun.id, {
153
+ name: 'Merger',
154
+ capabilities: options.mergerCapabilities ?? { mergeWorkspace: true },
155
+ });
156
+ const mainWorkspace = await prisma.workspace.create({
157
+ data: {
158
+ taskId: task.id,
159
+ branchName: 'team-main',
160
+ baseBranch: 'main',
161
+ worktreePath: path.join(testDir, 'team-main'),
162
+ status: 'ACTIVE',
163
+ },
164
+ });
165
+ await prisma.teamRun.update({
166
+ where: { id: teamRun.id },
167
+ data: { mainWorkspaceId: mainWorkspace.id },
168
+ });
169
+ const childWorkspace = await prisma.workspace.create({
170
+ data: {
171
+ taskId: task.id,
172
+ parentWorkspaceId: mainWorkspace.id,
173
+ ownerMemberId: owner.id,
174
+ branchName: 'dedicated-child',
175
+ baseBranch: mainWorkspace.branchName,
176
+ worktreePath: path.join(testDir, 'dedicated-child'),
177
+ status: options.childStatus ?? 'ACTIVE',
178
+ },
179
+ });
180
+ const request = await prisma.workRequest.create({
181
+ data: {
182
+ teamRunId: teamRun.id,
183
+ requesterMemberId: null,
184
+ requesterType: 'user',
185
+ targetMemberId: merger.id,
186
+ triggerMessageId: await createRoomMessageId(teamRun.id),
187
+ instruction: 'merge workspace',
188
+ status: 'STARTED',
189
+ },
190
+ });
191
+ const invocation = await prisma.agentInvocation.create({
192
+ data: {
193
+ teamRunId: teamRun.id,
194
+ workRequestId: request.id,
195
+ memberId: merger.id,
196
+ workspaceId: mainWorkspace.id,
197
+ status: 'RUNNING',
198
+ },
199
+ });
200
+ if (options.reviewVerdict) {
201
+ await prisma.workspaceVerdict.create({
202
+ data: {
203
+ workspaceId: childWorkspace.id,
204
+ teamRunId: teamRun.id,
205
+ kind: 'REVIEW',
206
+ verdict: options.reviewVerdict,
207
+ reviewedSha: options.reviewSha ?? 'child-head-sha',
208
+ reviewerMemberId: reviewer.id,
209
+ reason: 'reviewed',
210
+ sequence: 1,
211
+ },
212
+ });
213
+ }
214
+ return { task, teamRun, owner, reviewer, merger, mainWorkspace, childWorkspace, invocation };
215
+ }
216
+ async function createMergeInvocation(teamRunId, memberId, workspaceId) {
217
+ const request = await prisma.workRequest.create({
218
+ data: {
219
+ teamRunId,
220
+ requesterMemberId: null,
221
+ requesterType: 'user',
222
+ targetMemberId: memberId,
223
+ triggerMessageId: await createRoomMessageId(teamRunId),
224
+ instruction: 'merge workspace',
225
+ status: 'STARTED',
226
+ },
227
+ });
228
+ return prisma.agentInvocation.create({
229
+ data: {
230
+ teamRunId,
231
+ workRequestId: request.id,
232
+ memberId,
233
+ workspaceId,
234
+ status: 'RUNNING',
235
+ },
236
+ });
237
+ }
238
+ async function createRoomMessageId(teamRunId) {
239
+ const message = await prisma.roomMessage.create({
240
+ data: {
241
+ teamRunId,
242
+ senderType: 'user',
243
+ senderId: null,
244
+ senderInvocationId: null,
245
+ kind: 'chat',
246
+ content: 'trigger',
247
+ mentions: '[]',
248
+ },
249
+ });
250
+ return message.id;
251
+ }
121
252
  function mockCreatedWorktreePath(branchName) {
122
253
  const worktreePath = path.join(testDir, 'created-worktrees', branchName);
123
254
  fs.mkdirSync(path.join(worktreePath, '.git'), { recursive: true });
@@ -147,6 +278,9 @@ describe('WorkspaceService TeamRun workspace lifecycle', () => {
147
278
  if (args[0] === 'rev-parse' && args.includes('--abbrev-ref')) {
148
279
  return 'main\n';
149
280
  }
281
+ if (args[0] === 'rev-parse' && args[1] === 'HEAD') {
282
+ return 'child-head-sha\n';
283
+ }
150
284
  if (args[0] === 'status') {
151
285
  return '';
152
286
  }
@@ -169,6 +303,16 @@ describe('WorkspaceService TeamRun workspace lifecycle', () => {
169
303
  sourceBranch: 'dedicated-child',
170
304
  targetBranch: 'team-main',
171
305
  });
306
+ getGitOperationStatusMock.mockResolvedValue({
307
+ operation: 'idle',
308
+ conflictedFiles: [],
309
+ conflictOp: null,
310
+ ahead: 1,
311
+ behind: 0,
312
+ hasUncommittedChanges: false,
313
+ uncommittedCount: 0,
314
+ untrackedCount: 0,
315
+ });
172
316
  service = new WorkspaceService();
173
317
  await prisma.agentInvocation.deleteMany();
174
318
  await prisma.workRequest.deleteMany();
@@ -581,7 +725,25 @@ describe('WorkspaceService TeamRun workspace lifecycle', () => {
581
725
  status: 'ACTIVE',
582
726
  },
583
727
  });
584
- const sha = await service.merge(childWorkspace.id, 'merge child');
728
+ const reviewer = await addTeamMember(teamRun.id, { name: 'Reviewer', capabilities: { readDiff: true } });
729
+ const merger = await addTeamMember(teamRun.id, { name: 'Merger', capabilities: { mergeWorkspace: true } });
730
+ const invocation = await createMergeInvocation(teamRun.id, merger.id, mainWorkspace.id);
731
+ await prisma.workspaceVerdict.create({
732
+ data: {
733
+ workspaceId: childWorkspace.id,
734
+ teamRunId: teamRun.id,
735
+ kind: 'REVIEW',
736
+ verdict: 'APPROVED',
737
+ reviewedSha: 'child-head-sha',
738
+ reviewerMemberId: reviewer.id,
739
+ reason: 'approved',
740
+ },
741
+ });
742
+ const sha = await service.merge(childWorkspace.id, {
743
+ commitMessage: 'merge child',
744
+ lockOwnerId: invocation.id,
745
+ invocationId: invocation.id,
746
+ });
585
747
  expect(sha).toBe('child-merge-sha');
586
748
  expect(mergeIntoWorktreeMock).toHaveBeenCalledWith(childWorkspace.worktreePath, mainWorkspace.worktreePath, { commitMessage: 'merge child' });
587
749
  expect(mergeWorktreeMock).not.toHaveBeenCalled();
@@ -616,6 +778,20 @@ describe('WorkspaceService TeamRun workspace lifecycle', () => {
616
778
  status: 'ACTIVE',
617
779
  },
618
780
  });
781
+ const reviewer = await addTeamMember(teamRun.id, { name: 'Reviewer', capabilities: { readDiff: true } });
782
+ const merger = await addTeamMember(teamRun.id, { name: 'Merger', capabilities: { mergeWorkspace: true } });
783
+ const invocation = await createMergeInvocation(teamRun.id, merger.id, mainWorkspace.id);
784
+ await prisma.workspaceVerdict.create({
785
+ data: {
786
+ workspaceId: childWorkspace.id,
787
+ teamRunId: teamRun.id,
788
+ kind: 'REVIEW',
789
+ verdict: 'APPROVED',
790
+ reviewedSha: 'child-head-sha',
791
+ reviewerMemberId: reviewer.id,
792
+ reason: 'approved',
793
+ },
794
+ });
619
795
  await prisma.session.create({
620
796
  data: {
621
797
  workspaceId: mainWorkspace.id,
@@ -624,11 +800,791 @@ describe('WorkspaceService TeamRun workspace lifecycle', () => {
624
800
  status: 'RUNNING',
625
801
  },
626
802
  });
803
+ await expect(service.merge(childWorkspace.id, {
804
+ lockOwnerId: invocation.id,
805
+ invocationId: invocation.id,
806
+ })).rejects.toMatchObject({
807
+ code: 'PARENT_WORKSPACE_HAS_ACTIVE_SESSION',
808
+ });
809
+ expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
810
+ });
811
+ it('rejects TeamRun dedicated child merge without an approved review', async () => {
812
+ const { childWorkspace, invocation } = await createTeamRunChildMergeFixture();
813
+ await expect(service.merge(childWorkspace.id, {
814
+ lockOwnerId: invocation.id,
815
+ invocationId: invocation.id,
816
+ })).rejects.toMatchObject({
817
+ code: 'REVIEW_REQUIRED',
818
+ statusCode: 409,
819
+ });
820
+ expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
821
+ });
822
+ it('rejects TeamRun dedicated child merge when the approved review SHA is stale', async () => {
823
+ const { childWorkspace, invocation } = await createTeamRunChildMergeFixture({
824
+ reviewVerdict: 'APPROVED',
825
+ reviewSha: 'old-sha',
826
+ });
827
+ await expect(service.merge(childWorkspace.id, {
828
+ lockOwnerId: invocation.id,
829
+ invocationId: invocation.id,
830
+ })).rejects.toMatchObject({
831
+ code: 'REVIEW_STALE',
832
+ statusCode: 409,
833
+ });
834
+ expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
835
+ });
836
+ it('rejects TeamRun dedicated child merge when the latest review requests changes', async () => {
837
+ const { teamRun, childWorkspace, invocation, reviewer } = await createTeamRunChildMergeFixture({
838
+ reviewVerdict: 'APPROVED',
839
+ });
840
+ const sameCreatedAt = new Date(Date.UTC(2026, 0, 1, 0, 0, 0));
841
+ await prisma.workspaceVerdict.updateMany({
842
+ where: {
843
+ workspaceId: childWorkspace.id,
844
+ kind: 'REVIEW',
845
+ verdict: 'APPROVED',
846
+ },
847
+ data: {
848
+ createdAt: sameCreatedAt,
849
+ sequence: 1,
850
+ },
851
+ });
852
+ await prisma.workspaceVerdict.create({
853
+ data: {
854
+ workspaceId: childWorkspace.id,
855
+ teamRunId: teamRun.id,
856
+ kind: 'REVIEW',
857
+ verdict: 'CHANGES_REQUESTED',
858
+ reviewedSha: 'child-head-sha',
859
+ reviewerMemberId: reviewer.id,
860
+ reason: 'needs changes',
861
+ createdAt: sameCreatedAt,
862
+ sequence: 2,
863
+ },
864
+ });
865
+ await expect(service.merge(childWorkspace.id, {
866
+ lockOwnerId: invocation.id,
867
+ invocationId: invocation.id,
868
+ })).rejects.toMatchObject({
869
+ code: 'REVIEW_REQUIRED',
870
+ statusCode: 409,
871
+ });
872
+ expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
873
+ });
874
+ it('rejects ACTIVE TeamRun dedicated child merge when the invocation identity is missing', async () => {
875
+ const { childWorkspace } = await createTeamRunChildMergeFixture({
876
+ reviewVerdict: 'APPROVED',
877
+ });
627
878
  await expect(service.merge(childWorkspace.id)).rejects.toMatchObject({
879
+ code: 'TEAM_RUN_MERGE_INVOCATION_REQUIRED',
880
+ statusCode: 403,
881
+ });
882
+ expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
883
+ });
884
+ it('rejects ACTIVE TeamRun dedicated child merge when the invocation identity is invalid', async () => {
885
+ const { childWorkspace } = await createTeamRunChildMergeFixture({
886
+ reviewVerdict: 'APPROVED',
887
+ });
888
+ await expect(service.merge(childWorkspace.id, {
889
+ lockOwnerId: 'missing-invocation',
890
+ invocationId: 'missing-invocation',
891
+ })).rejects.toMatchObject({
892
+ code: 'FORBIDDEN',
893
+ statusCode: 403,
894
+ });
895
+ expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
896
+ });
897
+ it('rejects TeamRun dedicated child merge when the owner approved their own workspace', async () => {
898
+ const { childWorkspace, invocation } = await createTeamRunChildMergeFixture({
899
+ reviewVerdict: 'APPROVED',
900
+ reviewerIsOwner: true,
901
+ });
902
+ await expect(service.merge(childWorkspace.id, {
903
+ lockOwnerId: invocation.id,
904
+ invocationId: invocation.id,
905
+ })).rejects.toMatchObject({
906
+ code: 'SELF_REVIEW_FORBIDDEN',
907
+ statusCode: 409,
908
+ });
909
+ expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
910
+ });
911
+ it('rejects TeamRun dedicated child merge when the owner has active work', async () => {
912
+ const { teamRun, owner, childWorkspace, invocation } = await createTeamRunChildMergeFixture({
913
+ reviewVerdict: 'APPROVED',
914
+ });
915
+ const ownerRequest = await prisma.workRequest.create({
916
+ data: {
917
+ teamRunId: teamRun.id,
918
+ requesterMemberId: null,
919
+ requesterType: 'user',
920
+ targetMemberId: owner.id,
921
+ triggerMessageId: await createRoomMessageId(teamRun.id),
922
+ instruction: 'continue writing',
923
+ status: 'STARTED',
924
+ },
925
+ });
926
+ await prisma.agentInvocation.create({
927
+ data: {
928
+ teamRunId: teamRun.id,
929
+ workRequestId: ownerRequest.id,
930
+ memberId: owner.id,
931
+ workspaceId: childWorkspace.id,
932
+ status: 'RUNNING',
933
+ },
934
+ });
935
+ await expect(service.merge(childWorkspace.id, {
936
+ lockOwnerId: invocation.id,
937
+ invocationId: invocation.id,
938
+ })).rejects.toMatchObject({
939
+ code: 'OWNER_HAS_ACTIVE_INVOCATION',
940
+ statusCode: 409,
941
+ });
942
+ expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
943
+ });
944
+ it('rejects TeamRun dedicated child merge when the parent has active write sessions after review passes', async () => {
945
+ const { mainWorkspace, childWorkspace, invocation } = await createTeamRunChildMergeFixture({
946
+ reviewVerdict: 'APPROVED',
947
+ });
948
+ await prisma.session.create({
949
+ data: {
950
+ workspaceId: mainWorkspace.id,
951
+ agentType: 'CODEX',
952
+ prompt: 'write on parent',
953
+ status: 'RUNNING',
954
+ },
955
+ });
956
+ await expect(service.merge(childWorkspace.id, {
957
+ lockOwnerId: invocation.id,
958
+ invocationId: invocation.id,
959
+ })).rejects.toMatchObject({
628
960
  code: 'PARENT_WORKSPACE_HAS_ACTIVE_SESSION',
961
+ statusCode: 409,
962
+ });
963
+ expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
964
+ });
965
+ it('rejects TeamRun dedicated child merge for an invocation without mergeWorkspace capability', async () => {
966
+ const { childWorkspace, invocation } = await createTeamRunChildMergeFixture({
967
+ mergerCapabilities: { mergeWorkspace: false },
968
+ reviewVerdict: 'APPROVED',
969
+ });
970
+ await expect(service.merge(childWorkspace.id, {
971
+ lockOwnerId: invocation.id,
972
+ invocationId: invocation.id,
973
+ })).rejects.toMatchObject({
974
+ code: 'TEAM_RUN_MEMBER_CAPABILITY_REQUIRED',
975
+ statusCode: 403,
976
+ });
977
+ expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
978
+ });
979
+ it('returns idempotent success when a TeamRun dedicated child workspace is already merged', async () => {
980
+ const { childWorkspace, invocation } = await createTeamRunChildMergeFixture({
981
+ childStatus: 'MERGED',
982
+ });
983
+ const sha = await service.merge(childWorkspace.id, {
984
+ lockOwnerId: invocation.id,
985
+ invocationId: invocation.id,
986
+ });
987
+ expect(sha).toBe('child-head-sha');
988
+ expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
989
+ });
990
+ it('lists mergeable TeamRun workspaces with review, activity, and behind warnings', async () => {
991
+ const { teamRun, childWorkspace } = await createTeamRunChildMergeFixture({
992
+ reviewVerdict: 'APPROVED',
993
+ });
994
+ getGitOperationStatusMock.mockResolvedValueOnce({
995
+ operation: 'idle',
996
+ conflictedFiles: [],
997
+ conflictOp: null,
998
+ ahead: 2,
999
+ behind: 1,
1000
+ hasUncommittedChanges: false,
1001
+ uncommittedCount: 0,
1002
+ untrackedCount: 0,
1003
+ });
1004
+ const response = await service.listTeamRunMergeableWorkspaces(teamRun.id);
1005
+ expect(response).toMatchObject({
1006
+ teamRunId: teamRun.id,
1007
+ taskId: childWorkspace.taskId,
1008
+ workspaces: [
1009
+ expect.objectContaining({
1010
+ workspaceId: childWorkspace.id,
1011
+ headSha: 'child-head-sha',
1012
+ mergeReady: true,
1013
+ latestReview: expect.objectContaining({
1014
+ verdict: 'APPROVED',
1015
+ reviewedSha: 'child-head-sha',
1016
+ matchesHead: true,
1017
+ isSelfReview: false,
1018
+ }),
1019
+ git: expect.objectContaining({
1020
+ aheadOfMain: 2,
1021
+ behindMain: 1,
1022
+ clean: true,
1023
+ }),
1024
+ blockers: [
1025
+ expect.objectContaining({
1026
+ code: 'BEHIND_MAIN',
1027
+ severity: 'WARNING',
1028
+ }),
1029
+ ],
1030
+ }),
1031
+ ],
1032
+ });
1033
+ });
1034
+ it('reports readiness blockers for stale reviews, owner activity, and parent activity', async () => {
1035
+ const { teamRun, owner, childWorkspace, mainWorkspace } = await createTeamRunChildMergeFixture({
1036
+ reviewVerdict: 'APPROVED',
1037
+ reviewSha: 'old-sha',
1038
+ });
1039
+ const ownerRequest = await prisma.workRequest.create({
1040
+ data: {
1041
+ teamRunId: teamRun.id,
1042
+ requesterMemberId: null,
1043
+ requesterType: 'user',
1044
+ targetMemberId: owner.id,
1045
+ triggerMessageId: await createRoomMessageId(teamRun.id),
1046
+ instruction: 'continue writing',
1047
+ status: 'STARTED',
1048
+ },
1049
+ });
1050
+ await prisma.agentInvocation.create({
1051
+ data: {
1052
+ teamRunId: teamRun.id,
1053
+ workRequestId: ownerRequest.id,
1054
+ memberId: owner.id,
1055
+ workspaceId: childWorkspace.id,
1056
+ status: 'RUNNING',
1057
+ },
1058
+ });
1059
+ await prisma.session.create({
1060
+ data: {
1061
+ workspaceId: mainWorkspace.id,
1062
+ agentType: 'CODEX',
1063
+ prompt: 'write on parent',
1064
+ status: 'RUNNING',
1065
+ },
1066
+ });
1067
+ const response = await service.listTeamRunMergeableWorkspaces(teamRun.id);
1068
+ const item = response.workspaces[0];
1069
+ expect(item.mergeReady).toBe(false);
1070
+ expect(item.blockers.map((entry) => entry.code)).toEqual(expect.arrayContaining([
1071
+ 'REVIEW_STALE',
1072
+ 'OWNER_HAS_ACTIVE_INVOCATION',
1073
+ 'PARENT_WORKSPACE_HAS_ACTIVE_SESSION',
1074
+ ]));
1075
+ });
1076
+ it('dry-runs batch member merges without changing workspace state', async () => {
1077
+ const { teamRun, childWorkspace, invocation, merger } = await createTeamRunChildMergeFixture({
1078
+ reviewVerdict: 'APPROVED',
1079
+ });
1080
+ const result = await service.mergeTeamRunMembers(teamRun.id, {
1081
+ invocationId: invocation.id,
1082
+ requesterMemberId: merger.id,
1083
+ dryRun: true,
1084
+ });
1085
+ expect(result.summary).toMatchObject({
1086
+ requested: 1,
1087
+ considered: 1,
1088
+ wouldMerge: 1,
1089
+ merged: 0,
1090
+ });
1091
+ expect(result.results[0]).toMatchObject({
1092
+ workspaceId: childWorkspace.id,
1093
+ status: 'WOULD_MERGE',
1094
+ });
1095
+ expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
1096
+ await expect(prisma.workspace.findUnique({ where: { id: childWorkspace.id } })).resolves.toMatchObject({
1097
+ status: 'ACTIVE',
1098
+ });
1099
+ });
1100
+ it('treats an explicit empty workspaceIds batch request as an empty no-op', async () => {
1101
+ const { teamRun, childWorkspace, invocation, merger } = await createTeamRunChildMergeFixture({
1102
+ reviewVerdict: 'APPROVED',
1103
+ });
1104
+ const result = await service.mergeTeamRunMembers(teamRun.id, {
1105
+ invocationId: invocation.id,
1106
+ requesterMemberId: merger.id,
1107
+ workspaceIds: [],
1108
+ });
1109
+ expect(result.summary).toMatchObject({
1110
+ requested: 0,
1111
+ considered: 0,
1112
+ merged: 0,
1113
+ skipped: 0,
1114
+ conflicts: 0,
1115
+ failed: 0,
1116
+ });
1117
+ expect(result.requestedWorkspaceIds).toEqual([]);
1118
+ expect(result.results).toEqual([]);
1119
+ expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
1120
+ await expect(prisma.workspace.findUnique({ where: { id: childWorkspace.id } })).resolves.toMatchObject({
1121
+ status: 'ACTIVE',
1122
+ });
1123
+ });
1124
+ it('batch merge defaults to only merge-ready member workspaces', async () => {
1125
+ const { teamRun, childWorkspace, invocation, merger, mainWorkspace } = await createTeamRunChildMergeFixture({
1126
+ reviewVerdict: 'APPROVED',
1127
+ });
1128
+ const blockedOwner = await addTeamMember(teamRun.id, { name: 'Blocked owner' });
1129
+ const blockedWorkspace = await prisma.workspace.create({
1130
+ data: {
1131
+ taskId: childWorkspace.taskId,
1132
+ parentWorkspaceId: mainWorkspace.id,
1133
+ ownerMemberId: blockedOwner.id,
1134
+ branchName: 'blocked-child',
1135
+ baseBranch: mainWorkspace.branchName,
1136
+ worktreePath: path.join(testDir, 'blocked-child'),
1137
+ status: 'ACTIVE',
1138
+ },
1139
+ });
1140
+ const result = await service.mergeTeamRunMembers(teamRun.id, {
1141
+ invocationId: invocation.id,
1142
+ requesterMemberId: merger.id,
1143
+ });
1144
+ expect(result.summary).toMatchObject({
1145
+ requested: 2,
1146
+ considered: 2,
1147
+ merged: 1,
1148
+ skipped: 1,
1149
+ });
1150
+ expect(result.results).toEqual(expect.arrayContaining([
1151
+ expect.objectContaining({
1152
+ workspaceId: childWorkspace.id,
1153
+ status: 'MERGED',
1154
+ sha: 'child-merge-sha',
1155
+ }),
1156
+ expect.objectContaining({
1157
+ workspaceId: blockedWorkspace.id,
1158
+ status: 'SKIPPED',
1159
+ code: 'REVIEW_REQUIRED',
1160
+ }),
1161
+ ]));
1162
+ expect(mergeIntoWorktreeMock).toHaveBeenCalledTimes(1);
1163
+ });
1164
+ it('batch merge returns idempotent results for already merged workspaces', async () => {
1165
+ const { teamRun, childWorkspace, invocation, merger } = await createTeamRunChildMergeFixture({
1166
+ childStatus: 'MERGED',
1167
+ });
1168
+ const result = await service.mergeTeamRunMembers(teamRun.id, {
1169
+ invocationId: invocation.id,
1170
+ requesterMemberId: merger.id,
1171
+ });
1172
+ expect(result.summary).toMatchObject({
1173
+ requested: 1,
1174
+ considered: 1,
1175
+ alreadyMerged: 1,
1176
+ merged: 0,
1177
+ });
1178
+ expect(result.results[0]).toMatchObject({
1179
+ workspaceId: childWorkspace.id,
1180
+ status: 'ALREADY_MERGED',
1181
+ sha: 'child-head-sha',
1182
+ });
1183
+ expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
1184
+ });
1185
+ it('rejects batch member merge when invocation lacks mergeWorkspace capability', async () => {
1186
+ const { teamRun, invocation, merger } = await createTeamRunChildMergeFixture({
1187
+ mergerCapabilities: { mergeWorkspace: false },
1188
+ reviewVerdict: 'APPROVED',
1189
+ });
1190
+ await expect(service.mergeTeamRunMembers(teamRun.id, {
1191
+ invocationId: invocation.id,
1192
+ requesterMemberId: merger.id,
1193
+ })).rejects.toMatchObject({
1194
+ code: 'TEAM_RUN_MEMBER_CAPABILITY_REQUIRED',
1195
+ statusCode: 403,
629
1196
  });
630
1197
  expect(mergeIntoWorktreeMock).not.toHaveBeenCalled();
631
1198
  });
1199
+ it('continues batch member merge after per-workspace failure', async () => {
1200
+ const { teamRun, childWorkspace, invocation, merger, mainWorkspace } = await createTeamRunChildMergeFixture({
1201
+ reviewVerdict: 'APPROVED',
1202
+ });
1203
+ const secondOwner = await addTeamMember(teamRun.id, { name: 'Second owner' });
1204
+ const secondWorkspace = await prisma.workspace.create({
1205
+ data: {
1206
+ taskId: childWorkspace.taskId,
1207
+ parentWorkspaceId: mainWorkspace.id,
1208
+ ownerMemberId: secondOwner.id,
1209
+ branchName: 'second-child',
1210
+ baseBranch: mainWorkspace.branchName,
1211
+ worktreePath: path.join(testDir, 'second-child'),
1212
+ status: 'ACTIVE',
1213
+ },
1214
+ });
1215
+ await prisma.workspaceVerdict.create({
1216
+ data: {
1217
+ workspaceId: secondWorkspace.id,
1218
+ teamRunId: teamRun.id,
1219
+ kind: 'REVIEW',
1220
+ verdict: 'APPROVED',
1221
+ reviewedSha: 'child-head-sha',
1222
+ reviewerMemberId: merger.id,
1223
+ reason: 'approved',
1224
+ sequence: 1,
1225
+ },
1226
+ });
1227
+ mergeIntoWorktreeMock
1228
+ .mockRejectedValueOnce(Object.assign(new Error('git failed'), { code: 'GIT_ERROR' }))
1229
+ .mockResolvedValueOnce({
1230
+ sha: 'second-merge-sha',
1231
+ sourceBranch: 'second-child',
1232
+ targetBranch: 'team-main',
1233
+ });
1234
+ const result = await service.mergeTeamRunMembers(teamRun.id, {
1235
+ invocationId: invocation.id,
1236
+ requesterMemberId: merger.id,
1237
+ });
1238
+ expect(result.summary).toMatchObject({
1239
+ requested: 2,
1240
+ considered: 2,
1241
+ failed: 1,
1242
+ merged: 1,
1243
+ });
1244
+ expect(result.results).toEqual(expect.arrayContaining([
1245
+ expect.objectContaining({
1246
+ workspaceId: childWorkspace.id,
1247
+ status: 'FAILED',
1248
+ code: 'GIT_ERROR',
1249
+ }),
1250
+ expect.objectContaining({
1251
+ workspaceId: secondWorkspace.id,
1252
+ status: 'MERGED',
1253
+ sha: 'second-merge-sha',
1254
+ }),
1255
+ ]));
1256
+ expect(mergeIntoWorktreeMock).toHaveBeenCalledTimes(2);
1257
+ });
1258
+ it('returns conflict item details from batch member merge', async () => {
1259
+ const { teamRun, childWorkspace, invocation, merger } = await createTeamRunChildMergeFixture({
1260
+ reviewVerdict: 'APPROVED',
1261
+ });
1262
+ const conflictError = Object.assign(new Error('Merge conflict in files: file.txt'), {
1263
+ name: 'MergeConflictError',
1264
+ code: 'MERGE_CONFLICT',
1265
+ conflictedFiles: ['file.txt'],
1266
+ sourceBranch: childWorkspace.branchName,
1267
+ targetBranch: 'team-main',
1268
+ sourceWorkspaceId: childWorkspace.id,
1269
+ targetWorkspaceId: 'main-workspace',
1270
+ });
1271
+ mergeIntoWorktreeMock.mockRejectedValueOnce(conflictError);
1272
+ const result = await service.mergeTeamRunMembers(teamRun.id, {
1273
+ invocationId: invocation.id,
1274
+ requesterMemberId: merger.id,
1275
+ stopOnConflict: true,
1276
+ });
1277
+ expect(result.summary).toMatchObject({
1278
+ conflicts: 1,
1279
+ merged: 0,
1280
+ });
1281
+ expect(result.results[0]).toMatchObject({
1282
+ workspaceId: childWorkspace.id,
1283
+ status: 'CONFLICT',
1284
+ code: 'MERGE_CONFLICT',
1285
+ conflictedFiles: ['file.txt'],
1286
+ });
1287
+ });
1288
+ it('releases the project merge lock after batch member merge failure', async () => {
1289
+ const lockService = new TeamLockService();
1290
+ service = new WorkspaceService(lockService);
1291
+ const { teamRun, invocation, merger } = await createTeamRunChildMergeFixture({
1292
+ reviewVerdict: 'APPROVED',
1293
+ });
1294
+ mergeIntoWorktreeMock.mockRejectedValueOnce(Object.assign(new Error('git failed'), { code: 'GIT_ERROR' }));
1295
+ await service.mergeTeamRunMembers(teamRun.id, {
1296
+ invocationId: invocation.id,
1297
+ requesterMemberId: merger.id,
1298
+ });
1299
+ expect(lockService.listLocks()).toEqual([]);
1300
+ });
1301
+ it('reuses an existing project merge lock held by the same invocation', async () => {
1302
+ const lockService = new TeamLockService();
1303
+ service = new WorkspaceService(lockService);
1304
+ const { task, teamRun, childWorkspace, invocation, merger } = await createTeamRunChildMergeFixture({
1305
+ reviewVerdict: 'APPROVED',
1306
+ });
1307
+ const lockKey = `project:${task.projectId}:merge`;
1308
+ expect(lockService.acquire(invocation.id, [lockKey])).toBe(true);
1309
+ const result = await service.mergeTeamRunMembers(teamRun.id, {
1310
+ invocationId: invocation.id,
1311
+ requesterMemberId: merger.id,
1312
+ });
1313
+ expect(result.results[0]).toMatchObject({
1314
+ workspaceId: childWorkspace.id,
1315
+ status: 'MERGED',
1316
+ });
1317
+ expect(lockService.isHeldBy(invocation.id, lockKey)).toBe(true);
1318
+ lockService.release(invocation.id, [lockKey]);
1319
+ });
1320
+ it('records workspace review and test verdicts for the current HEAD', async () => {
1321
+ const { teamRun, childWorkspace } = await createTeamRunChildMergeFixture();
1322
+ const reviewer = await addTeamMember(teamRun.id, {
1323
+ name: 'Review And Test',
1324
+ capabilities: { readDiff: true, runCommands: true },
1325
+ });
1326
+ const review = await service.recordVerdict(childWorkspace.id, {
1327
+ kind: 'REVIEW',
1328
+ verdict: 'APPROVED',
1329
+ reviewedSha: 'child-head-sha',
1330
+ reviewerMemberId: reviewer.id,
1331
+ reason: 'looks good',
1332
+ });
1333
+ const test = await service.recordVerdict(childWorkspace.id, {
1334
+ kind: 'TEST',
1335
+ verdict: 'PASSED',
1336
+ reviewedSha: 'child-head-sha',
1337
+ reviewerMemberId: reviewer.id,
1338
+ reason: 'unit tests passed',
1339
+ });
1340
+ expect(review).toMatchObject({
1341
+ workspaceId: childWorkspace.id,
1342
+ kind: 'REVIEW',
1343
+ verdict: 'APPROVED',
1344
+ reviewedSha: 'child-head-sha',
1345
+ reviewerMemberId: reviewer.id,
1346
+ sequence: 1,
1347
+ });
1348
+ expect(test).toMatchObject({
1349
+ workspaceId: childWorkspace.id,
1350
+ kind: 'TEST',
1351
+ verdict: 'PASSED',
1352
+ reviewedSha: 'child-head-sha',
1353
+ reviewerMemberId: reviewer.id,
1354
+ sequence: 1,
1355
+ });
1356
+ await expect(service.listVerdicts(childWorkspace.id)).resolves.toHaveLength(2);
1357
+ });
1358
+ it('resolves targeted invocation identity for verdicts recorded on the source workspace', async () => {
1359
+ const { teamRun, mainWorkspace, childWorkspace } = await createTeamRunChildMergeFixture();
1360
+ const reviewer = await addTeamMember(teamRun.id, {
1361
+ name: 'Targeted Reviewer',
1362
+ capabilities: { readDiff: true },
1363
+ });
1364
+ const executionWorkspace = await prisma.workspace.create({
1365
+ data: {
1366
+ taskId: childWorkspace.taskId,
1367
+ parentWorkspaceId: mainWorkspace.id,
1368
+ ownerMemberId: reviewer.id,
1369
+ branchName: 'targeted-review-execution',
1370
+ baseBranch: childWorkspace.branchName,
1371
+ worktreePath: path.join(testDir, 'targeted-review-execution'),
1372
+ status: 'ACTIVE',
1373
+ },
1374
+ });
1375
+ const request = await prisma.workRequest.create({
1376
+ data: {
1377
+ teamRunId: teamRun.id,
1378
+ requesterMemberId: null,
1379
+ requesterType: 'user',
1380
+ targetMemberId: reviewer.id,
1381
+ targetKind: 'WORKSPACE_COMMIT',
1382
+ targetPurpose: 'REVIEW',
1383
+ targetSourceWorkspaceId: childWorkspace.id,
1384
+ targetHeadSha: 'child-head-sha',
1385
+ targetBranchName: childWorkspace.branchName,
1386
+ triggerMessageId: await createRoomMessageId(teamRun.id),
1387
+ instruction: 'review target commit',
1388
+ status: 'STARTED',
1389
+ },
1390
+ });
1391
+ const invocation = await prisma.agentInvocation.create({
1392
+ data: {
1393
+ teamRunId: teamRun.id,
1394
+ workRequestId: request.id,
1395
+ memberId: reviewer.id,
1396
+ workspaceId: executionWorkspace.id,
1397
+ targetKind: 'WORKSPACE_COMMIT',
1398
+ targetPurpose: 'REVIEW',
1399
+ targetSourceWorkspaceId: childWorkspace.id,
1400
+ targetHeadSha: 'child-head-sha',
1401
+ targetBranchName: childWorkspace.branchName,
1402
+ targetSyncStatus: 'SYNCED',
1403
+ status: 'RUNNING',
1404
+ },
1405
+ });
1406
+ const identity = await service.resolveInvocationMemberForWorkspace(childWorkspace.id, invocation.id);
1407
+ expect(identity).toMatchObject({
1408
+ teamRunId: teamRun.id,
1409
+ memberId: reviewer.id,
1410
+ invocationId: invocation.id,
1411
+ });
1412
+ const review = await service.recordVerdict(childWorkspace.id, {
1413
+ kind: 'REVIEW',
1414
+ verdict: 'APPROVED',
1415
+ reviewedSha: 'child-head-sha',
1416
+ reviewerMemberId: identity.memberId,
1417
+ reason: 'target commit reviewed',
1418
+ });
1419
+ expect(review).toMatchObject({
1420
+ workspaceId: childWorkspace.id,
1421
+ kind: 'REVIEW',
1422
+ verdict: 'APPROVED',
1423
+ reviewedSha: 'child-head-sha',
1424
+ reviewerMemberId: reviewer.id,
1425
+ });
1426
+ });
1427
+ it('rejects targeted workspace verdicts when the source workspace HEAD moved past targetHeadSha', async () => {
1428
+ const { teamRun, mainWorkspace, childWorkspace } = await createTeamRunChildMergeFixture();
1429
+ const reviewer = await addTeamMember(teamRun.id, {
1430
+ name: 'Stale Target Reviewer',
1431
+ capabilities: { readDiff: true },
1432
+ });
1433
+ const executionWorkspace = await prisma.workspace.create({
1434
+ data: {
1435
+ taskId: childWorkspace.taskId,
1436
+ parentWorkspaceId: mainWorkspace.id,
1437
+ ownerMemberId: reviewer.id,
1438
+ branchName: 'stale-target-execution',
1439
+ baseBranch: childWorkspace.branchName,
1440
+ worktreePath: path.join(testDir, 'stale-target-execution'),
1441
+ status: 'ACTIVE',
1442
+ },
1443
+ });
1444
+ const request = await prisma.workRequest.create({
1445
+ data: {
1446
+ teamRunId: teamRun.id,
1447
+ requesterMemberId: null,
1448
+ requesterType: 'user',
1449
+ targetMemberId: reviewer.id,
1450
+ targetKind: 'WORKSPACE_COMMIT',
1451
+ targetPurpose: 'REVIEW',
1452
+ targetSourceWorkspaceId: childWorkspace.id,
1453
+ targetHeadSha: 'old-target-sha',
1454
+ targetBranchName: childWorkspace.branchName,
1455
+ triggerMessageId: await createRoomMessageId(teamRun.id),
1456
+ instruction: 'review old target',
1457
+ status: 'STARTED',
1458
+ },
1459
+ });
1460
+ const invocation = await prisma.agentInvocation.create({
1461
+ data: {
1462
+ teamRunId: teamRun.id,
1463
+ workRequestId: request.id,
1464
+ memberId: reviewer.id,
1465
+ workspaceId: executionWorkspace.id,
1466
+ targetKind: 'WORKSPACE_COMMIT',
1467
+ targetPurpose: 'REVIEW',
1468
+ targetSourceWorkspaceId: childWorkspace.id,
1469
+ targetHeadSha: 'old-target-sha',
1470
+ targetBranchName: childWorkspace.branchName,
1471
+ targetSyncStatus: 'SYNCED',
1472
+ status: 'RUNNING',
1473
+ },
1474
+ });
1475
+ const identity = await service.resolveInvocationMemberForWorkspace(childWorkspace.id, invocation.id);
1476
+ await expect(service.recordVerdict(childWorkspace.id, {
1477
+ kind: 'REVIEW',
1478
+ verdict: 'APPROVED',
1479
+ reviewedSha: 'child-head-sha',
1480
+ reviewerMemberId: identity.memberId,
1481
+ expectedTargetHeadSha: identity.targetHeadSha,
1482
+ })).rejects.toMatchObject({
1483
+ code: 'TARGET_VERDICT_STALE',
1484
+ statusCode: 409,
1485
+ });
1486
+ });
1487
+ it('rejects targeted workspace verdicts when reviewedSha differs from targetHeadSha', async () => {
1488
+ const { teamRun, mainWorkspace, childWorkspace } = await createTeamRunChildMergeFixture();
1489
+ const reviewer = await addTeamMember(teamRun.id, {
1490
+ name: 'Target Mismatch Reviewer',
1491
+ capabilities: { readDiff: true },
1492
+ });
1493
+ const executionWorkspace = await prisma.workspace.create({
1494
+ data: {
1495
+ taskId: childWorkspace.taskId,
1496
+ parentWorkspaceId: mainWorkspace.id,
1497
+ ownerMemberId: reviewer.id,
1498
+ branchName: 'target-mismatch-execution',
1499
+ baseBranch: childWorkspace.branchName,
1500
+ worktreePath: path.join(testDir, 'target-mismatch-execution'),
1501
+ status: 'ACTIVE',
1502
+ },
1503
+ });
1504
+ const request = await prisma.workRequest.create({
1505
+ data: {
1506
+ teamRunId: teamRun.id,
1507
+ requesterMemberId: null,
1508
+ requesterType: 'user',
1509
+ targetMemberId: reviewer.id,
1510
+ targetKind: 'WORKSPACE_COMMIT',
1511
+ targetPurpose: 'REVIEW',
1512
+ targetSourceWorkspaceId: childWorkspace.id,
1513
+ targetHeadSha: 'child-head-sha',
1514
+ targetBranchName: childWorkspace.branchName,
1515
+ triggerMessageId: await createRoomMessageId(teamRun.id),
1516
+ instruction: 'review target',
1517
+ status: 'STARTED',
1518
+ },
1519
+ });
1520
+ const invocation = await prisma.agentInvocation.create({
1521
+ data: {
1522
+ teamRunId: teamRun.id,
1523
+ workRequestId: request.id,
1524
+ memberId: reviewer.id,
1525
+ workspaceId: executionWorkspace.id,
1526
+ targetKind: 'WORKSPACE_COMMIT',
1527
+ targetPurpose: 'REVIEW',
1528
+ targetSourceWorkspaceId: childWorkspace.id,
1529
+ targetHeadSha: 'child-head-sha',
1530
+ targetBranchName: childWorkspace.branchName,
1531
+ targetSyncStatus: 'SYNCED',
1532
+ status: 'RUNNING',
1533
+ },
1534
+ });
1535
+ const identity = await service.resolveInvocationMemberForWorkspace(childWorkspace.id, invocation.id);
1536
+ await expect(service.recordVerdict(childWorkspace.id, {
1537
+ kind: 'REVIEW',
1538
+ verdict: 'APPROVED',
1539
+ reviewedSha: 'different-sha',
1540
+ reviewerMemberId: identity.memberId,
1541
+ expectedTargetHeadSha: identity.targetHeadSha,
1542
+ })).rejects.toMatchObject({
1543
+ code: 'TARGET_VERDICT_SHA_MISMATCH',
1544
+ statusCode: 409,
1545
+ });
1546
+ });
1547
+ it('rejects workspace verdicts when the submitted SHA does not match HEAD', async () => {
1548
+ const { childWorkspace, reviewer } = await createTeamRunChildMergeFixture();
1549
+ await expect(service.recordVerdict(childWorkspace.id, {
1550
+ kind: 'REVIEW',
1551
+ verdict: 'APPROVED',
1552
+ reviewedSha: 'old-sha',
1553
+ reviewerMemberId: reviewer.id,
1554
+ })).rejects.toMatchObject({
1555
+ code: 'WORKSPACE_VERDICT_SHA_MISMATCH',
1556
+ statusCode: 409,
1557
+ });
1558
+ });
1559
+ it('rejects workspace verdicts when the current member lacks the required capability', async () => {
1560
+ const { teamRun, childWorkspace } = await createTeamRunChildMergeFixture();
1561
+ const memberWithoutReview = await addTeamMember(teamRun.id, {
1562
+ name: 'No Review Capability',
1563
+ capabilities: { runCommands: true },
1564
+ });
1565
+ const memberWithoutTest = await addTeamMember(teamRun.id, {
1566
+ name: 'No Test Capability',
1567
+ capabilities: { readDiff: true },
1568
+ });
1569
+ await expect(service.recordVerdict(childWorkspace.id, {
1570
+ kind: 'REVIEW',
1571
+ verdict: 'APPROVED',
1572
+ reviewedSha: 'child-head-sha',
1573
+ reviewerMemberId: memberWithoutReview.id,
1574
+ })).rejects.toMatchObject({
1575
+ code: 'TEAM_RUN_MEMBER_CAPABILITY_REQUIRED',
1576
+ statusCode: 403,
1577
+ });
1578
+ await expect(service.recordVerdict(childWorkspace.id, {
1579
+ kind: 'TEST',
1580
+ verdict: 'PASSED',
1581
+ reviewedSha: 'child-head-sha',
1582
+ reviewerMemberId: memberWithoutTest.id,
1583
+ })).rejects.toMatchObject({
1584
+ code: 'TEAM_RUN_MEMBER_CAPABILITY_REQUIRED',
1585
+ statusCode: 403,
1586
+ });
1587
+ });
632
1588
  it('rejects final TeamRun main workspace merge while dedicated children are not final', async () => {
633
1589
  const { task, teamRun, member } = await createTeamRunWithMember();
634
1590
  await prisma.task.update({