github-issue-tower-defence-management 1.178.0 → 1.179.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 (33) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -0
  3. package/bin/adapter/entry-points/cli/index.js +2 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +36 -0
  6. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  7. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +23 -7
  8. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  9. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +1 -7
  10. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
  11. package/bin/domain/usecases/resolveNextStepAgentDispatchRepetition.js +31 -0
  12. package/bin/domain/usecases/resolveNextStepAgentDispatchRepetition.js.map +1 -0
  13. package/package.json +1 -1
  14. package/src/adapter/entry-points/cli/index.test.ts +2 -0
  15. package/src/adapter/entry-points/cli/index.ts +6 -0
  16. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +215 -0
  17. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +79 -1
  18. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +128 -0
  19. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +43 -15
  20. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +21 -26
  21. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +5 -13
  22. package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +2 -0
  23. package/src/domain/usecases/resolveNextStepAgentDispatchRepetition.test.ts +97 -0
  24. package/src/domain/usecases/resolveNextStepAgentDispatchRepetition.ts +49 -0
  25. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  26. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +3 -1
  27. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  28. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +0 -1
  29. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  30. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts +0 -1
  31. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
  32. package/types/domain/usecases/resolveNextStepAgentDispatchRepetition.d.ts +21 -0
  33. package/types/domain/usecases/resolveNextStepAgentDispatchRepetition.d.ts.map +1 -0
@@ -109,6 +109,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
109
109
  setIssueAgentField: jest.Mock;
110
110
  searchIssue: jest.Mock;
111
111
  createNewIssue: jest.Mock;
112
+ updateNextActionDate: jest.Mock;
112
113
  };
113
114
  let mockIssueCommentRepository: {
114
115
  getCommentsFromIssue: jest.Mock;
@@ -155,6 +156,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
155
156
  setIssueAgentField: jest.fn().mockResolvedValue(undefined),
156
157
  searchIssue: jest.fn().mockResolvedValue([]),
157
158
  createNewIssue: jest.fn().mockResolvedValue(42),
159
+ updateNextActionDate: jest.fn().mockResolvedValue(undefined),
158
160
  };
159
161
 
160
162
  mockIssueCommentRepository = {
@@ -947,6 +949,85 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
947
949
  expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
948
950
  });
949
951
 
952
+ it('should comment the repeated dispatch when the declared nextStepAgent is already the agent field value', async () => {
953
+ const issue = createMockIssue({
954
+ url: 'https://github.com/user/repo/issues/1',
955
+ status: 'Preparation',
956
+ agent: 'accounting',
957
+ });
958
+
959
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
960
+ mockIssueRepository.get.mockResolvedValue(issue);
961
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
962
+ createMockComment({
963
+ content:
964
+ 'From: :robot: triager\n```json\n{"nextStepAgent": "accounting", "nextStep": null}\n```',
965
+ }),
966
+ ]);
967
+
968
+ await useCase.run({
969
+ projectUrl: 'https://github.com/users/user/projects/1',
970
+ issueUrl: 'https://github.com/user/repo/issues/1',
971
+ thresholdForAutoReject: 3,
972
+ workflowBlockerResolvedWebhookUrl: null,
973
+ allowedIssueAuthors: null,
974
+ });
975
+
976
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
977
+ mockProject,
978
+ expect.anything(),
979
+ 'awaiting-workspace-id',
980
+ );
981
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
982
+ expect.anything(),
983
+ expect.stringContaining('Next step agent dispatch repeated: accounting'),
984
+ );
985
+ });
986
+
987
+ it('should escalate to Failed Preparation when the declared nextStepAgent was already dispatched up to the threshold without a report', async () => {
988
+ const issue = createMockIssue({
989
+ url: 'https://github.com/user/repo/issues/1',
990
+ status: 'Preparation',
991
+ agent: 'accounting',
992
+ });
993
+
994
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
995
+ mockIssueRepository.get.mockResolvedValue(issue);
996
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
997
+ createMockComment({
998
+ content:
999
+ 'From: :robot: triager\n```json\n{"nextStepAgent": "accounting", "nextStep": null}\n```',
1000
+ }),
1001
+ createMockComment({
1002
+ content: 'Next step agent dispatch repeated: accounting',
1003
+ }),
1004
+ createMockComment({
1005
+ content: 'Next step agent dispatch repeated: accounting',
1006
+ }),
1007
+ ]);
1008
+
1009
+ await useCase.run({
1010
+ projectUrl: 'https://github.com/users/user/projects/1',
1011
+ issueUrl: 'https://github.com/user/repo/issues/1',
1012
+ thresholdForAutoReject: 3,
1013
+ workflowBlockerResolvedWebhookUrl: null,
1014
+ allowedIssueAuthors: null,
1015
+ });
1016
+
1017
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
1018
+ mockProject,
1019
+ expect.anything(),
1020
+ 'failed-preparation-id',
1021
+ );
1022
+ expect(mockIssueRepository.setIssueAgentField).not.toHaveBeenCalled();
1023
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
1024
+ expect.anything(),
1025
+ expect.stringContaining(
1026
+ 'Failed to receive a report from the dispatched agent for 3 times',
1027
+ ),
1028
+ );
1029
+ });
1030
+
950
1031
  it('should not modify labels when nextStepAgent is specified', async () => {
951
1032
  const issue = createMockIssue({
952
1033
  url: 'https://github.com/user/repo/issues/1',
@@ -4301,4 +4382,138 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
4301
4382
  expect(mockIssueRepository.createNewIssue).not.toHaveBeenCalled();
4302
4383
  });
4303
4384
  });
4385
+
4386
+ describe('when deferPreparation is true', () => {
4387
+ const issueUrl = 'https://github.com/user/repo/issues/1';
4388
+
4389
+ it('sets nextActionDate to start of tomorrow and returns item to Awaiting Workspace without creating any issue', async () => {
4390
+ const now = new Date('2026-08-24T10:00:00Z');
4391
+ jest.useFakeTimers();
4392
+ jest.setSystemTime(now);
4393
+
4394
+ const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4395
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
4396
+ mockIssueRepository.get.mockResolvedValue(issue);
4397
+
4398
+ await useCase.run({
4399
+ projectUrl: 'https://github.com/users/user/projects/1',
4400
+ issueUrl,
4401
+ thresholdForAutoReject: 3,
4402
+ workflowBlockerResolvedWebhookUrl: null,
4403
+ allowedIssueAuthors: null,
4404
+ deferPreparation: true,
4405
+ });
4406
+
4407
+ expect(mockIssueRepository.updateNextActionDate).toHaveBeenCalledTimes(1);
4408
+ expect(mockIssueRepository.updateNextActionDate).toHaveBeenCalledWith(
4409
+ issueUrl,
4410
+ mockProject,
4411
+ new Date('2026-08-25T00:00:00'),
4412
+ );
4413
+
4414
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
4415
+ mockProject,
4416
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
4417
+ 'awaiting-workspace-id',
4418
+ );
4419
+
4420
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledTimes(1);
4421
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
4422
+ expect.objectContaining({ url: issueUrl }),
4423
+ expect.stringContaining('2026-08-25'),
4424
+ );
4425
+
4426
+ expect(mockIssueRepository.searchIssue).not.toHaveBeenCalled();
4427
+ expect(mockIssueRepository.createNewIssue).not.toHaveBeenCalled();
4428
+ expect(mockIssueRepository.setDependedIssueUrl).not.toHaveBeenCalled();
4429
+ });
4430
+
4431
+ it('takes precedence over missingAgentName and defers without creating any issue', async () => {
4432
+ const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4433
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
4434
+ mockIssueRepository.get.mockResolvedValue(issue);
4435
+
4436
+ await useCase.run({
4437
+ projectUrl: 'https://github.com/users/user/projects/1',
4438
+ issueUrl,
4439
+ thresholdForAutoReject: 3,
4440
+ workflowBlockerResolvedWebhookUrl: null,
4441
+ allowedIssueAuthors: null,
4442
+ deferPreparation: true,
4443
+ missingAgentName: 'some-agent',
4444
+ manager: 'alice',
4445
+ });
4446
+
4447
+ expect(mockIssueRepository.updateNextActionDate).toHaveBeenCalledTimes(1);
4448
+ expect(mockIssueRepository.searchIssue).not.toHaveBeenCalled();
4449
+ expect(mockIssueRepository.createNewIssue).not.toHaveBeenCalled();
4450
+ expect(mockIssueRepository.setDependedIssueUrl).not.toHaveBeenCalled();
4451
+ });
4452
+
4453
+ it('does not defer when deferPreparation is false', async () => {
4454
+ const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4455
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
4456
+ mockIssueRepository.get.mockResolvedValue(issue);
4457
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
4458
+ createMockComment({ content: 'From: :robot: Test report' }),
4459
+ ]);
4460
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
4461
+
4462
+ await useCase.run({
4463
+ projectUrl: 'https://github.com/users/user/projects/1',
4464
+ issueUrl,
4465
+ thresholdForAutoReject: 3,
4466
+ workflowBlockerResolvedWebhookUrl: null,
4467
+ allowedIssueAuthors: null,
4468
+ deferPreparation: false,
4469
+ });
4470
+
4471
+ expect(mockIssueRepository.updateNextActionDate).not.toHaveBeenCalled();
4472
+ });
4473
+
4474
+ it('states the session stop reason in the deferral comment', async () => {
4475
+ const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4476
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
4477
+ mockIssueRepository.get.mockResolvedValue(issue);
4478
+
4479
+ await useCase.run({
4480
+ projectUrl: 'https://github.com/users/user/projects/1',
4481
+ issueUrl,
4482
+ thresholdForAutoReject: 3,
4483
+ workflowBlockerResolvedWebhookUrl: null,
4484
+ allowedIssueAuthors: null,
4485
+ deferPreparation: true,
4486
+ sessionErrorLine:
4487
+ 'Task failed 3 consecutive times with terminal_reason=api_error',
4488
+ });
4489
+
4490
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledTimes(1);
4491
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
4492
+ expect.objectContaining({ url: issueUrl }),
4493
+ expect.stringContaining(
4494
+ 'Task failed 3 consecutive times with terminal_reason=api_error',
4495
+ ),
4496
+ );
4497
+ });
4498
+
4499
+ it('states that no stop reason was captured when sessionErrorLine is absent', async () => {
4500
+ const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4501
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
4502
+ mockIssueRepository.get.mockResolvedValue(issue);
4503
+
4504
+ await useCase.run({
4505
+ projectUrl: 'https://github.com/users/user/projects/1',
4506
+ issueUrl,
4507
+ thresholdForAutoReject: 3,
4508
+ workflowBlockerResolvedWebhookUrl: null,
4509
+ allowedIssueAuthors: null,
4510
+ deferPreparation: true,
4511
+ });
4512
+
4513
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
4514
+ expect.objectContaining({ url: issueUrl }),
4515
+ expect.stringContaining('(not captured)'),
4516
+ );
4517
+ });
4518
+ });
4304
4519
  });
@@ -29,8 +29,12 @@ import { Project } from '../entities/Project';
29
29
  import { ensureAgentOptionAndGetId } from './ensureAgentOptionAndGetId';
30
30
  import { extractNextStepAgent } from './extractNextStepAgent';
31
31
  import { findLastAgentReport } from './findLastAgentReport';
32
- import { issueReactivationTriggerIsPending } from './issueReactivationTriggerIsPending';
32
+ import {
33
+ issueReactivationTriggerIsPending,
34
+ issueReactivationTriggerStartOfTomorrow,
35
+ } from './issueReactivationTriggerIsPending';
33
36
  import { normalizeReportBody } from './normalizeReportBody';
37
+ import { resolveNextStepAgentDispatchRepetition } from './resolveNextStepAgentDispatchRepetition';
34
38
 
35
39
  export class IssueNotFoundError extends Error {
36
40
  constructor(issueUrl: string) {
@@ -79,6 +83,7 @@ export class NotifyFinishedIssuePreparationUseCase {
79
83
  | 'setIssueAgentField'
80
84
  | 'searchIssue'
81
85
  | 'createNewIssue'
86
+ | 'updateNextActionDate'
82
87
  >,
83
88
  private readonly issueCommentRepository: Pick<
84
89
  IssueCommentRepository,
@@ -110,6 +115,7 @@ export class NotifyFinishedIssuePreparationUseCase {
110
115
  sessionErrorLine?: string | null;
111
116
  manager?: string | null;
112
117
  developerAgentName?: string | null;
118
+ deferPreparation?: boolean | null;
113
119
  }): Promise<void> => {
114
120
  const project = await this.projectRepository.getByUrl(params.projectUrl);
115
121
 
@@ -153,6 +159,16 @@ export class NotifyFinishedIssuePreparationUseCase {
153
159
  );
154
160
  }
155
161
 
162
+ if (params.deferPreparation) {
163
+ await this.handleTransientFailureDeferral(
164
+ issue,
165
+ project,
166
+ awaitingWorkspaceStatusOption,
167
+ params.sessionErrorLine ?? null,
168
+ );
169
+ return;
170
+ }
171
+
156
172
  if (params.missingAgentName) {
157
173
  await this.handleMissingAgentDefinition(
158
174
  issue,
@@ -229,7 +245,37 @@ export class NotifyFinishedIssuePreparationUseCase {
229
245
  const nextStepAgent = lastAgentReport
230
246
  ? extractNextStepAgent(lastAgentReport.content)
231
247
  : null;
248
+ const commentsAfterLastAgentReport = lastAgentReport
249
+ ? comments.slice(comments.indexOf(lastAgentReport) + 1)
250
+ : [];
232
251
  if (nextStepAgent !== null) {
252
+ const repetition = resolveNextStepAgentDispatchRepetition({
253
+ agentFieldValue: issue.agent,
254
+ nextStepAgent,
255
+ commentsAfterLastAgentReport,
256
+ isTrustedAuthor,
257
+ thresholdForAutoReject: params.thresholdForAutoReject,
258
+ });
259
+ if (repetition.type === 'escalateToFailedPreparation') {
260
+ issue.status = FAILED_PREPARATION_STATUS_NAME;
261
+ await this.issueRepository.update(issue, project);
262
+ await this.issueRepository.updateStatus(
263
+ project,
264
+ issue,
265
+ failedPreparationStatusOption.id,
266
+ );
267
+ await this.patchConsoleTab(issue);
268
+ await this.issueCommentRepository.createComment(
269
+ issue,
270
+ repetition.comment,
271
+ );
272
+ await this.sendWorkflowBlockerNotification(
273
+ params.issueUrl,
274
+ params.workflowBlockerResolvedWebhookUrl,
275
+ project,
276
+ );
277
+ return;
278
+ }
233
279
  const agentOptionId = await this.ensureAgentOptionAndGetId(
234
280
  project,
235
281
  nextStepAgent,
@@ -249,6 +295,12 @@ export class NotifyFinishedIssuePreparationUseCase {
249
295
  awaitingWorkspaceStatusOption.id,
250
296
  );
251
297
  await this.patchConsoleTab(issue);
298
+ if (repetition.type === 'dispatchAgain') {
299
+ await this.issueCommentRepository.createComment(
300
+ issue,
301
+ repetition.comment,
302
+ );
303
+ }
252
304
  return;
253
305
  }
254
306
 
@@ -382,6 +434,32 @@ export class NotifyFinishedIssuePreparationUseCase {
382
434
  );
383
435
  };
384
436
 
437
+ private handleTransientFailureDeferral = async (
438
+ issue: Issue,
439
+ project: Project,
440
+ awaitingWorkspaceStatusOption: { id: string },
441
+ sessionErrorLine: string | null,
442
+ ): Promise<void> => {
443
+ const tomorrow = issueReactivationTriggerStartOfTomorrow(new Date());
444
+ await this.issueRepository.updateNextActionDate(
445
+ issue.url,
446
+ project,
447
+ tomorrow,
448
+ );
449
+ issue.status = AWAITING_WORKSPACE_STATUS_NAME;
450
+ await this.issueRepository.update(issue, project);
451
+ await this.issueRepository.updateStatus(
452
+ project,
453
+ issue,
454
+ awaitingWorkspaceStatusOption.id,
455
+ );
456
+ await this.patchConsoleTab(issue);
457
+ await this.issueCommentRepository.createComment(
458
+ issue,
459
+ `Preparation deferred due to transient failure; item reactivates from ${tomorrow.toISOString().split('T')[0]}\nSession stop reason: ${sessionErrorLine ?? '(not captured)'}`,
460
+ );
461
+ };
462
+
385
463
  private handleMissingAgentDefinition = async (
386
464
  issue: Issue,
387
465
  project: Project,
@@ -260,6 +260,134 @@ describe('RevertOrphanedPreparationUseCase', () => {
260
260
  expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('1');
261
261
  });
262
262
 
263
+ it('should transition to Failed Preparation when the declared next step agent is already assigned and the repetition threshold is met', async () => {
264
+ mockProject.agent = {
265
+ name: 'agent',
266
+ fieldId: 'agent-field-id',
267
+ options: [
268
+ {
269
+ id: 'agent-option-developer',
270
+ name: 'developer',
271
+ color: 'GRAY',
272
+ description: '',
273
+ },
274
+ ],
275
+ };
276
+ const stuckIssue = createMockIssue({
277
+ url: 'https://github.com/user/repo/issues/10',
278
+ status: 'Preparation',
279
+ agent: 'developer',
280
+ });
281
+ mockIssueRepository.getAllIssues.mockResolvedValue({
282
+ project: mockProject,
283
+ issues: [stuckIssue],
284
+ cacheUsed: false,
285
+ });
286
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
287
+ stdout: '',
288
+ stderr: '',
289
+ exitCode: 1,
290
+ });
291
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
292
+ {
293
+ author: 'bot',
294
+ content:
295
+ 'From: :robot: triager\n\n```json\n{"nextStep":null,"nextStepAgent":"developer"}\n```',
296
+ createdAt: new Date('2024-01-02T00:00:00Z'),
297
+ },
298
+ {
299
+ author: 'bot',
300
+ content: 'Next step agent dispatch repeated: developer',
301
+ createdAt: new Date('2024-01-02T01:00:00Z'),
302
+ },
303
+ {
304
+ author: 'bot',
305
+ content: 'Next step agent dispatch repeated: developer',
306
+ createdAt: new Date('2024-01-02T02:00:00Z'),
307
+ },
308
+ ]);
309
+
310
+ await useCase.run({
311
+ projectUrl: 'https://github.com/user/repo',
312
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
313
+ thresholdForAutoReject: 3,
314
+ allowedIssueAuthors: ['bot'],
315
+ });
316
+
317
+ expect(mockIssueRepository.setIssueAgentField.mock.calls).toEqual([]);
318
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
319
+ expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('5');
320
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
321
+ stuckIssue,
322
+ expect.stringContaining(
323
+ 'Failed to receive a report from the dispatched agent for 3 times',
324
+ ),
325
+ );
326
+ });
327
+
328
+ it('should record the repeated dispatch even when the project has no Failed Preparation status', async () => {
329
+ mockProject.status.statuses = mockProject.status.statuses.filter(
330
+ (status) => status.name !== 'Failed Preparation',
331
+ );
332
+ mockProject.agent = {
333
+ name: 'agent',
334
+ fieldId: 'agent-field-id',
335
+ options: [
336
+ {
337
+ id: 'agent-option-developer',
338
+ name: 'developer',
339
+ color: 'GRAY',
340
+ description: '',
341
+ },
342
+ ],
343
+ };
344
+ const stuckIssue = createMockIssue({
345
+ url: 'https://github.com/user/repo/issues/10',
346
+ status: 'Preparation',
347
+ agent: 'developer',
348
+ });
349
+ mockIssueRepository.getAllIssues.mockResolvedValue({
350
+ project: mockProject,
351
+ issues: [stuckIssue],
352
+ cacheUsed: false,
353
+ });
354
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
355
+ stdout: '',
356
+ stderr: '',
357
+ exitCode: 1,
358
+ });
359
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
360
+ {
361
+ author: 'bot',
362
+ content:
363
+ 'From: :robot: triager\n\n```json\n{"nextStep":null,"nextStepAgent":"developer"}\n```',
364
+ createdAt: new Date('2024-01-02T00:00:00Z'),
365
+ },
366
+ {
367
+ author: 'bot',
368
+ content: 'Next step agent dispatch repeated: developer',
369
+ createdAt: new Date('2024-01-02T01:00:00Z'),
370
+ },
371
+ {
372
+ author: 'bot',
373
+ content: 'Next step agent dispatch repeated: developer',
374
+ createdAt: new Date('2024-01-02T02:00:00Z'),
375
+ },
376
+ ]);
377
+
378
+ await useCase.run({
379
+ projectUrl: 'https://github.com/user/repo',
380
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
381
+ thresholdForAutoReject: 3,
382
+ allowedIssueAuthors: ['bot'],
383
+ });
384
+
385
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
386
+ stuckIssue,
387
+ expect.stringContaining('Next step agent dispatch repeated: developer'),
388
+ );
389
+ });
390
+
263
391
  it('should ignore a next step agent designated by an author outside the allowed issue authors', async () => {
264
392
  mockProject.agent = {
265
393
  name: 'agent',
@@ -26,6 +26,7 @@ import { extractNextStepAgent } from './extractNextStepAgent';
26
26
  import { findLastAgentReport } from './findLastAgentReport';
27
27
  import { ensureAgentOptionAndGetId } from './ensureAgentOptionAndGetId';
28
28
  import { normalizeReportBody } from './normalizeReportBody';
29
+ import { resolveNextStepAgentDispatchRepetition } from './resolveNextStepAgentDispatchRepetition';
29
30
 
30
31
  const ORPHANED_PREPARATION_REJECTION_DETAIL = 'ORPHANED_PREPARATION';
31
32
 
@@ -121,11 +122,44 @@ export class RevertOrphanedPreparationUseCase {
121
122
  if (!isStillInPreparation) {
122
123
  continue;
123
124
  }
124
- const nextStepAgent = this.resolveNextStepAgent(
125
- comments,
126
- params.allowedIssueAuthors,
125
+ const lastAgentReport = findLastAgentReport(comments, (author) =>
126
+ isAuthorAuthorizedForAutoStatusCheck(
127
+ author,
128
+ params.allowedIssueAuthors,
129
+ ),
127
130
  );
131
+ const nextStepAgent = lastAgentReport
132
+ ? extractNextStepAgent(lastAgentReport.content)
133
+ : null;
128
134
  if (nextStepAgent !== null) {
135
+ const repetition = resolveNextStepAgentDispatchRepetition({
136
+ agentFieldValue: issue.agent,
137
+ nextStepAgent,
138
+ commentsAfterLastAgentReport: lastAgentReport
139
+ ? comments.slice(comments.indexOf(lastAgentReport) + 1)
140
+ : [],
141
+ isTrustedAuthor: (author) =>
142
+ isAuthorAuthorizedForAutoStatusCheck(
143
+ author,
144
+ params.allowedIssueAuthors,
145
+ ),
146
+ thresholdForAutoReject: params.thresholdForAutoReject,
147
+ });
148
+ if (
149
+ repetition.type === 'escalateToFailedPreparation' &&
150
+ failedPreparationStatusOption
151
+ ) {
152
+ await this.issueRepository.updateStatus(
153
+ project,
154
+ issue,
155
+ failedPreparationStatusOption.id,
156
+ );
157
+ await this.issueCommentRepository.createComment(
158
+ issue,
159
+ repetition.comment,
160
+ );
161
+ continue;
162
+ }
129
163
  const agentOptionId = await ensureAgentOptionAndGetId(
130
164
  this.projectRepository,
131
165
  project,
@@ -143,6 +177,12 @@ export class RevertOrphanedPreparationUseCase {
143
177
  issue,
144
178
  awaitingWorkspaceStatusOption.id,
145
179
  );
180
+ if (repetition.type !== 'notRepeated') {
181
+ await this.issueCommentRepository.createComment(
182
+ issue,
183
+ repetition.comment,
184
+ );
185
+ }
146
186
  continue;
147
187
  }
148
188
  if (outcome === 'returnToLabelSelectedAgent') {
@@ -346,18 +386,6 @@ export class RevertOrphanedPreparationUseCase {
346
386
  return [pr];
347
387
  };
348
388
 
349
- private resolveNextStepAgent = (
350
- comments: Comment[],
351
- allowedIssueAuthors: string[] | null | undefined,
352
- ): string | null => {
353
- const lastAgentReport = findLastAgentReport(comments, (author) =>
354
- isAuthorAuthorizedForAutoStatusCheck(author, allowedIssueAuthors),
355
- );
356
- return lastAgentReport
357
- ? extractNextStepAgent(lastAgentReport.content)
358
- : null;
359
- };
360
-
361
389
  private reportBodyHasNextStep = (body: string): boolean => {
362
390
  const reportMatch = normalizeReportBody(body).match(
363
391
  /```json\n([\s\S]*?)\n```/,