github-issue-tower-defence-management 1.122.3 → 1.122.5

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 (84) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +8 -4
  3. package/bin/adapter/entry-points/console/consoleDoneStore.js +19 -5
  4. package/bin/adapter/entry-points/console/consoleDoneStore.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +14 -2
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/consoleListsWriter.js +2 -0
  8. package/bin/adapter/entry-points/handlers/consoleListsWriter.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +4 -2
  10. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  11. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +12 -7
  12. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  13. package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js +60 -11
  14. package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js.map +1 -1
  15. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js +2 -2
  16. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js.map +1 -1
  17. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js +1 -0
  18. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js.map +1 -1
  19. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +14 -11
  20. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
  21. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +64 -7
  22. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
  23. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +18 -7
  24. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  25. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +64 -19
  26. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/adapter/entry-points/console/consoleDoneStore.test.ts +38 -0
  29. package/src/adapter/entry-points/console/consoleDoneStore.ts +29 -4
  30. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +11 -0
  31. package/src/adapter/entry-points/handlers/consoleListsWriter.test.ts +72 -0
  32. package/src/adapter/entry-points/handlers/consoleListsWriter.ts +3 -0
  33. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +46 -7
  34. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +6 -0
  35. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +102 -37
  36. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +22 -17
  37. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +1 -1
  38. package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.test.ts +223 -0
  39. package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.ts +94 -10
  40. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.test.ts +7 -6
  41. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.ts +3 -3
  42. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.test.ts +12 -2
  43. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.ts +1 -0
  44. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +99 -49
  45. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +24 -14
  46. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +196 -4
  47. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +91 -7
  48. package/src/domain/entities/LiveSessionActivitySnapshot.ts +2 -1
  49. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +187 -51
  50. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +32 -15
  51. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +356 -28
  52. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +94 -26
  53. package/src/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.ts +2 -2
  54. package/src/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.ts +8 -0
  55. package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +7 -6
  56. package/types/adapter/entry-points/console/consoleDoneStore.d.ts +2 -0
  57. package/types/adapter/entry-points/console/consoleDoneStore.d.ts.map +1 -1
  58. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  59. package/types/adapter/entry-points/handlers/consoleListsWriter.d.ts.map +1 -1
  60. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +2 -0
  61. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  62. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +4 -3
  63. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  64. package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts +13 -2
  65. package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts.map +1 -1
  66. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts +1 -1
  67. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts.map +1 -1
  68. package/types/adapter/repositories/ProcessListSessionSubAgentActivityRepository.d.ts.map +1 -1
  69. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +2 -2
  70. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
  71. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts +5 -1
  72. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
  73. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts +2 -1
  74. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts.map +1 -1
  75. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +3 -3
  76. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  77. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +4 -1
  78. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  79. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts +1 -1
  80. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts.map +1 -1
  81. package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts +8 -0
  82. package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts.map +1 -1
  83. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +5 -4
  84. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts.map +1 -1
@@ -4,6 +4,7 @@ import {
4
4
  parseHubTaskIssueUrlFromSessionName,
5
5
  isGitHubIssueOrPullRequestSessionName,
6
6
  DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
7
+ DEFAULT_UNANSWERED_OWNER_CALL_GRACE_SECONDS,
7
8
  DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
8
9
  DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
9
10
  DEFAULT_NOTIFICATION_STAGGER_SECONDS,
@@ -28,6 +29,8 @@ import { InteractiveLiveSession } from '../entities/InteractiveLiveSession';
28
29
  type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
29
30
 
30
31
  const MAIN_STALLED_SECTION = 'MAIN_STALLED_SECTION';
32
+ const MAIN_STALLED_STALE_OWNER_CALL_SECTION =
33
+ 'MAIN_STALLED_STALE_OWNER_CALL_SECTION';
31
34
  const SUBAGENT_SECTION = 'SUBAGENT_SECTION';
32
35
 
33
36
  class EveryNameRecentSet extends Set<string> {
@@ -66,6 +69,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
66
69
  }>,
67
70
  ): {
68
71
  mainSilentThresholdSeconds: number;
72
+ unansweredOwnerCallGraceSeconds: number;
69
73
  subAgentSilentThresholdSeconds: number;
70
74
  subAgentRunningThresholdSeconds: number;
71
75
  staggerSeconds: number;
@@ -75,6 +79,8 @@ describe('NotifySilentLiveSessionsUseCase', () => {
75
79
  now: Date;
76
80
  } => ({
77
81
  mainSilentThresholdSeconds: DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
82
+ unansweredOwnerCallGraceSeconds:
83
+ DEFAULT_UNANSWERED_OWNER_CALL_GRACE_SECONDS,
78
84
  subAgentSilentThresholdSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
79
85
  subAgentRunningThresholdSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
80
86
  staggerSeconds: DEFAULT_NOTIFICATION_STAGGER_SECONDS,
@@ -147,9 +153,9 @@ describe('NotifySilentLiveSessionsUseCase', () => {
147
153
  .mockResolvedValue(new Map<string, SubAgentActivity[]>()),
148
154
  };
149
155
  mockOwnerCallStatusProvider = {
150
- listSessionNamesWithUnansweredOwnerCall: jest
156
+ listUnansweredOwnerCallEpochSecondsBySessionName: jest
151
157
  .fn()
152
- .mockResolvedValue(new Set<string>()),
158
+ .mockResolvedValue(new Map<string, number>()),
153
159
  };
154
160
  mockNotificationRepository = {
155
161
  sendSelfCheckNotification: jest.fn().mockResolvedValue(undefined),
@@ -159,11 +165,20 @@ describe('NotifySilentLiveSessionsUseCase', () => {
159
165
  .fn()
160
166
  .mockResolvedValue(everyNameRecentSet()),
161
167
  saveCandidateSessionNames: jest.fn().mockResolvedValue(undefined),
168
+ loadAnnouncedRunningSubAgentLabels: jest
169
+ .fn()
170
+ .mockResolvedValue(new Set<string>()),
171
+ saveAnnouncedRunningSubAgentLabels: jest
172
+ .fn()
173
+ .mockResolvedValue(undefined),
162
174
  };
163
175
  mockMessageComposer = {
164
176
  composeMainStalledSection: jest
165
177
  .fn()
166
178
  .mockReturnValue(MAIN_STALLED_SECTION),
179
+ composeMainStalledWithStaleOwnerCallSection: jest
180
+ .fn()
181
+ .mockReturnValue(MAIN_STALLED_STALE_OWNER_CALL_SECTION),
167
182
  composeSubAgentSection: jest.fn().mockReturnValue(SUBAGENT_SECTION),
168
183
  };
169
184
  mockSleeper = {
@@ -243,6 +258,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
243
258
 
244
259
  it('exposes the default thresholds as named constants', () => {
245
260
  expect(DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS).toBe(600);
261
+ expect(DEFAULT_UNANSWERED_OWNER_CALL_GRACE_SECONDS).toBe(3600);
246
262
  expect(DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS).toBe(300);
247
263
  expect(DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS).toBe(900);
248
264
  expect(DEFAULT_NOTIFICATION_STAGGER_SECONDS).toBe(25);
@@ -365,36 +381,119 @@ describe('NotifySilentLiveSessionsUseCase', () => {
365
381
  mockSessionOutputActivityRepository.listSessionOutputActivities,
366
382
  ).toHaveBeenCalledWith(expectedMap);
367
383
  expect(
368
- mockOwnerCallStatusProvider.listSessionNamesWithUnansweredOwnerCall,
384
+ mockOwnerCallStatusProvider.listUnansweredOwnerCallEpochSecondsBySessionName,
369
385
  ).toHaveBeenCalledWith(expectedMap);
370
386
  expect(
371
387
  mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName,
372
388
  ).toHaveBeenCalledWith([GITHUB_SESSION], expectedMap);
373
389
  });
374
390
 
375
- it('suppresses the stalled section and sends nothing when an owner call is pending past the threshold', async () => {
391
+ it('suppresses the stalled section and sends nothing while the unanswered owner call is younger than the grace period', async () => {
392
+ setupSilentMainSession(GITHUB_SESSION);
393
+ mockOwnerCallStatusProvider.listUnansweredOwnerCallEpochSecondsBySessionName.mockResolvedValue(
394
+ new Map([
395
+ [
396
+ GITHUB_SESSION,
397
+ nowEpochSeconds - DEFAULT_UNANSWERED_OWNER_CALL_GRACE_SECONDS + 1,
398
+ ],
399
+ ]),
400
+ );
401
+
402
+ await useCase.run(runParams());
403
+
404
+ expect(
405
+ mockMessageComposer.composeMainStalledSection,
406
+ ).not.toHaveBeenCalled();
407
+ expect(
408
+ mockMessageComposer.composeMainStalledWithStaleOwnerCallSection,
409
+ ).not.toHaveBeenCalled();
410
+ expect(
411
+ mockNotificationRepository.sendSelfCheckNotification,
412
+ ).not.toHaveBeenCalled();
413
+ });
414
+
415
+ it('sends the stale-owner-call stalled section once the unanswered owner call reaches the grace period', async () => {
416
+ setupSilentMainSession(GITHUB_SESSION);
417
+ mockOwnerCallStatusProvider.listUnansweredOwnerCallEpochSecondsBySessionName.mockResolvedValue(
418
+ new Map([
419
+ [
420
+ GITHUB_SESSION,
421
+ nowEpochSeconds - DEFAULT_UNANSWERED_OWNER_CALL_GRACE_SECONDS,
422
+ ],
423
+ ]),
424
+ );
425
+
426
+ await useCase.run(runParams());
427
+
428
+ expect(
429
+ mockMessageComposer.composeMainStalledWithStaleOwnerCallSection,
430
+ ).toHaveBeenCalledWith(
431
+ DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
432
+ DEFAULT_UNANSWERED_OWNER_CALL_GRACE_SECONDS,
433
+ );
434
+ expect(
435
+ mockMessageComposer.composeMainStalledSection,
436
+ ).not.toHaveBeenCalled();
437
+ expect(
438
+ mockNotificationRepository.sendSelfCheckNotification,
439
+ ).toHaveBeenCalledWith(
440
+ GITHUB_SESSION,
441
+ MAIN_STALLED_STALE_OWNER_CALL_SECTION,
442
+ );
443
+ });
444
+
445
+ it('does not send the stale-owner-call section when the owner call is past the grace period but the main output is not silent past the threshold', async () => {
376
446
  setupLiveInteractiveSession(GITHUB_SESSION);
377
447
  mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
378
448
  [
379
449
  {
380
450
  sessionName: GITHUB_SESSION,
381
451
  lastOutputEpochSeconds:
382
- nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
452
+ nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS + 1,
383
453
  },
384
454
  ],
385
455
  );
386
- mockOwnerCallStatusProvider.listSessionNamesWithUnansweredOwnerCall.mockResolvedValue(
387
- new Set([GITHUB_SESSION]),
456
+ mockOwnerCallStatusProvider.listUnansweredOwnerCallEpochSecondsBySessionName.mockResolvedValue(
457
+ new Map([
458
+ [
459
+ GITHUB_SESSION,
460
+ nowEpochSeconds - DEFAULT_UNANSWERED_OWNER_CALL_GRACE_SECONDS,
461
+ ],
462
+ ]),
388
463
  );
389
464
 
390
465
  await useCase.run(runParams());
391
466
 
392
467
  expect(
393
- mockMessageComposer.composeMainStalledSection,
468
+ mockNotificationRepository.sendSelfCheckNotification,
394
469
  ).not.toHaveBeenCalled();
470
+ });
471
+
472
+ it('defers a first-cycle stale-owner-call candidate until it persists into the next cycle', async () => {
473
+ setupSilentMainSession(GITHUB_SESSION);
474
+ mockOwnerCallStatusProvider.listUnansweredOwnerCallEpochSecondsBySessionName.mockResolvedValue(
475
+ new Map([
476
+ [
477
+ GITHUB_SESSION,
478
+ nowEpochSeconds - DEFAULT_UNANSWERED_OWNER_CALL_GRACE_SECONDS,
479
+ ],
480
+ ]),
481
+ );
482
+ mockCandidateStateRepository.loadRecentCandidateSessionNames.mockResolvedValue(
483
+ new Set<string>(),
484
+ );
485
+
486
+ await useCase.run(runParams());
487
+
395
488
  expect(
396
489
  mockNotificationRepository.sendSelfCheckNotification,
397
490
  ).not.toHaveBeenCalled();
491
+ expect(
492
+ mockCandidateStateRepository.saveCandidateSessionNames,
493
+ ).toHaveBeenCalledWith({
494
+ sessionNames: [GITHUB_SESSION],
495
+ now,
496
+ });
398
497
  });
399
498
 
400
499
  it('sends the main stalled section when the session is silent past the threshold and not waiting on the owner', async () => {
@@ -408,8 +507,8 @@ describe('NotifySilentLiveSessionsUseCase', () => {
408
507
  },
409
508
  ],
410
509
  );
411
- mockOwnerCallStatusProvider.listSessionNamesWithUnansweredOwnerCall.mockResolvedValue(
412
- new Set<string>(),
510
+ mockOwnerCallStatusProvider.listUnansweredOwnerCallEpochSecondsBySessionName.mockResolvedValue(
511
+ new Map<string, number>(),
413
512
  );
414
513
 
415
514
  await useCase.run(runParams());
@@ -417,6 +516,9 @@ describe('NotifySilentLiveSessionsUseCase', () => {
417
516
  expect(mockMessageComposer.composeMainStalledSection).toHaveBeenCalledWith(
418
517
  DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
419
518
  );
519
+ expect(
520
+ mockMessageComposer.composeMainStalledWithStaleOwnerCallSection,
521
+ ).not.toHaveBeenCalled();
420
522
  expect(
421
523
  mockNotificationRepository.sendSelfCheckNotification,
422
524
  ).toHaveBeenCalledWith(GITHUB_SESSION, MAIN_STALLED_SECTION);
@@ -448,6 +550,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
448
550
  label: 'sub-process-1',
449
551
  silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
450
552
  runningSeconds: 60,
553
+ waitingOnExternalProcess: false,
451
554
  },
452
555
  ];
453
556
  mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
@@ -456,27 +559,23 @@ describe('NotifySilentLiveSessionsUseCase', () => {
456
559
 
457
560
  await useCase.run(runParams());
458
561
 
459
- expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith(
460
- subAgents,
461
- {
462
- subAgentSilentThresholdSeconds:
463
- DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
464
- subAgentRunningThresholdSeconds:
465
- DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
466
- },
467
- );
562
+ expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith({
563
+ idleSubAgents: subAgents,
564
+ longRunningSubAgents: [],
565
+ });
468
566
  expect(
469
567
  mockNotificationRepository.sendSelfCheckNotification,
470
568
  ).toHaveBeenCalledWith(GITHUB_SESSION, SUBAGENT_SECTION);
471
569
  });
472
570
 
473
- it('passes a configured running threshold through to the sub-agent section composer', async () => {
571
+ it('applies a configured running threshold when selecting long-running sub-agents', async () => {
474
572
  setupLiveInteractiveSession(GITHUB_SESSION);
475
573
  const subAgents: SubAgentActivity[] = [
476
574
  {
477
575
  label: 'sub-process-1',
478
576
  silentSeconds: 10,
479
577
  runningSeconds: 600,
578
+ waitingOnExternalProcess: false,
480
579
  },
481
580
  ];
482
581
  mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
@@ -488,14 +587,10 @@ describe('NotifySilentLiveSessionsUseCase', () => {
488
587
  subAgentRunningThresholdSeconds: 600,
489
588
  });
490
589
 
491
- expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith(
492
- subAgents,
493
- {
494
- subAgentSilentThresholdSeconds:
495
- DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
496
- subAgentRunningThresholdSeconds: 600,
497
- },
498
- );
590
+ expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith({
591
+ idleSubAgents: [],
592
+ longRunningSubAgents: subAgents,
593
+ });
499
594
  });
500
595
 
501
596
  it('excludes an owner-handover spawn from selection so no notification is sent', async () => {
@@ -656,6 +751,239 @@ describe('NotifySilentLiveSessionsUseCase', () => {
656
751
  });
657
752
  });
658
753
 
754
+ describe('sub-agent state-based reminder judgment', () => {
755
+ const hungSubAgent = (label: string): SubAgentActivity => ({
756
+ label,
757
+ silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
758
+ runningSeconds: 60,
759
+ waitingOnExternalProcess: false,
760
+ });
761
+ const waitingSubAgent = (label: string): SubAgentActivity => ({
762
+ label,
763
+ silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
764
+ runningSeconds: 60,
765
+ waitingOnExternalProcess: true,
766
+ });
767
+ const longRunningSubAgent = (
768
+ label: string,
769
+ waitingOnExternalProcess: boolean,
770
+ ): SubAgentActivity => ({
771
+ label,
772
+ silentSeconds: 30,
773
+ runningSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
774
+ waitingOnExternalProcess,
775
+ });
776
+
777
+ const setupSubAgents = (subAgents: SubAgentActivity[]): void => {
778
+ setupLiveInteractiveSession(GITHUB_SESSION);
779
+ mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
780
+ new Map([[GITHUB_SESSION, subAgents]]),
781
+ );
782
+ };
783
+
784
+ it('never selects a waiting sub-agent as a silent-reminder candidate', async () => {
785
+ setupSubAgents([waitingSubAgent('sub-process-1')]);
786
+
787
+ await useCase.run(runParams());
788
+
789
+ expect(mockMessageComposer.composeSubAgentSection).not.toHaveBeenCalled();
790
+ expect(
791
+ mockNotificationRepository.sendSelfCheckNotification,
792
+ ).not.toHaveBeenCalled();
793
+ });
794
+
795
+ it('notifies a hung sub-agent whose in-flight tool has no live process', async () => {
796
+ setupSubAgents([hungSubAgent('sub-process-1')]);
797
+
798
+ await useCase.run(runParams());
799
+
800
+ expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith({
801
+ idleSubAgents: [hungSubAgent('sub-process-1')],
802
+ longRunningSubAgents: [],
803
+ });
804
+ expect(
805
+ mockNotificationRepository.sendSelfCheckNotification,
806
+ ).toHaveBeenCalledWith(GITHUB_SESSION, SUBAGENT_SECTION);
807
+ });
808
+
809
+ it('keeps notifying a hung sub-agent on every cycle while the condition holds', async () => {
810
+ setupSubAgents([hungSubAgent('sub-process-1')]);
811
+
812
+ await useCase.run(runParams());
813
+ await useCase.run(runParams());
814
+
815
+ expect(
816
+ mockNotificationRepository.sendSelfCheckNotification,
817
+ ).toHaveBeenCalledTimes(2);
818
+ });
819
+
820
+ it('announces a running-threshold crossing for a waiting sub-agent without any idle section', async () => {
821
+ setupSubAgents([
822
+ {
823
+ label: 'sub-process-1',
824
+ silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
825
+ runningSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
826
+ waitingOnExternalProcess: true,
827
+ },
828
+ ]);
829
+
830
+ await useCase.run(runParams());
831
+
832
+ expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith({
833
+ idleSubAgents: [],
834
+ longRunningSubAgents: [
835
+ {
836
+ label: 'sub-process-1',
837
+ silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
838
+ runningSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
839
+ waitingOnExternalProcess: true,
840
+ },
841
+ ],
842
+ });
843
+ expect(
844
+ mockNotificationRepository.sendSelfCheckNotification,
845
+ ).toHaveBeenCalledWith(GITHUB_SESSION, SUBAGENT_SECTION);
846
+ });
847
+
848
+ it('records the announced running label only after the notification is sent', async () => {
849
+ setupSubAgents([longRunningSubAgent('sub-process-1', false)]);
850
+
851
+ await useCase.run(runParams());
852
+
853
+ expect(
854
+ mockCandidateStateRepository.saveAnnouncedRunningSubAgentLabels,
855
+ ).toHaveBeenCalledWith({
856
+ sessionName: GITHUB_SESSION,
857
+ labels: ['sub-process-1'],
858
+ now,
859
+ });
860
+ });
861
+
862
+ it('does not record an announced running label when the first-cycle debounce defers the send', async () => {
863
+ setupSubAgents([longRunningSubAgent('sub-process-1', false)]);
864
+ mockCandidateStateRepository.loadRecentCandidateSessionNames.mockResolvedValue(
865
+ new Set<string>(),
866
+ );
867
+
868
+ await useCase.run(runParams());
869
+
870
+ expect(
871
+ mockNotificationRepository.sendSelfCheckNotification,
872
+ ).not.toHaveBeenCalled();
873
+ expect(
874
+ mockCandidateStateRepository.saveAnnouncedRunningSubAgentLabels,
875
+ ).not.toHaveBeenCalled();
876
+ });
877
+
878
+ it('does not repeat the running announcement while the same label stays over the threshold', async () => {
879
+ setupSubAgents([longRunningSubAgent('sub-process-1', false)]);
880
+ mockCandidateStateRepository.loadAnnouncedRunningSubAgentLabels.mockResolvedValue(
881
+ new Set(['sub-process-1']),
882
+ );
883
+
884
+ await useCase.run(runParams());
885
+
886
+ expect(mockMessageComposer.composeSubAgentSection).not.toHaveBeenCalled();
887
+ expect(
888
+ mockNotificationRepository.sendSelfCheckNotification,
889
+ ).not.toHaveBeenCalled();
890
+ });
891
+
892
+ it('announces only the new label when another announced label is still over the threshold', async () => {
893
+ setupSubAgents([
894
+ longRunningSubAgent('sub-process-1', false),
895
+ longRunningSubAgent('sub-process-2', false),
896
+ ]);
897
+ mockCandidateStateRepository.loadAnnouncedRunningSubAgentLabels.mockResolvedValue(
898
+ new Set(['sub-process-1']),
899
+ );
900
+
901
+ await useCase.run(runParams());
902
+
903
+ expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith({
904
+ idleSubAgents: [],
905
+ longRunningSubAgents: [longRunningSubAgent('sub-process-2', false)],
906
+ });
907
+ expect(
908
+ mockCandidateStateRepository.saveAnnouncedRunningSubAgentLabels,
909
+ ).toHaveBeenCalledWith({
910
+ sessionName: GITHUB_SESSION,
911
+ labels: ['sub-process-1', 'sub-process-2'],
912
+ now,
913
+ });
914
+ });
915
+
916
+ it('clears the announced record when the label disappears from the sub-agent set', async () => {
917
+ setupSubAgents([]);
918
+ mockCandidateStateRepository.loadAnnouncedRunningSubAgentLabels.mockResolvedValue(
919
+ new Set(['sub-process-1']),
920
+ );
921
+
922
+ await useCase.run(runParams());
923
+
924
+ expect(
925
+ mockCandidateStateRepository.saveAnnouncedRunningSubAgentLabels,
926
+ ).toHaveBeenCalledWith({
927
+ sessionName: GITHUB_SESSION,
928
+ labels: [],
929
+ now,
930
+ });
931
+ });
932
+
933
+ it('announces exactly once per crossing across use-case instances sharing the persisted state', async () => {
934
+ setupSubAgents([longRunningSubAgent('sub-process-1', false)]);
935
+ const announcedLabelsBySessionName = new Map<string, string[]>();
936
+ mockCandidateStateRepository.loadAnnouncedRunningSubAgentLabels.mockImplementation(
937
+ async ({ sessionName }) =>
938
+ new Set(announcedLabelsBySessionName.get(sessionName) ?? []),
939
+ );
940
+ mockCandidateStateRepository.saveAnnouncedRunningSubAgentLabels.mockImplementation(
941
+ async ({ sessionName, labels }) => {
942
+ announcedLabelsBySessionName.set(sessionName, labels);
943
+ },
944
+ );
945
+
946
+ await useCase.run(runParams());
947
+ expect(
948
+ mockNotificationRepository.sendSelfCheckNotification,
949
+ ).toHaveBeenCalledTimes(1);
950
+
951
+ const secondUseCase = new NotifySilentLiveSessionsUseCase(
952
+ mockSnapshotProvider,
953
+ mockTranscriptResolver,
954
+ mockSessionOutputActivityRepository,
955
+ mockSubAgentActivityRepository,
956
+ mockOwnerCallStatusProvider,
957
+ mockNotificationRepository,
958
+ mockCandidateStateRepository,
959
+ mockMessageComposer,
960
+ mockSleeper,
961
+ mockHubTaskStatusResolver,
962
+ mockHubTaskStatusCacheRepository,
963
+ );
964
+ await secondUseCase.run(
965
+ runParams({ now: new Date(now.getTime() + 60 * 1000) }),
966
+ );
967
+
968
+ expect(
969
+ mockNotificationRepository.sendSelfCheckNotification,
970
+ ).toHaveBeenCalledTimes(1);
971
+ });
972
+
973
+ it('does not touch the announced-label record for a main-only reminder', async () => {
974
+ setupSilentMainSession(GITHUB_SESSION);
975
+
976
+ await useCase.run(runParams());
977
+
978
+ expect(
979
+ mockNotificationRepository.sendSelfCheckNotification,
980
+ ).toHaveBeenCalledWith(GITHUB_SESSION, MAIN_STALLED_SECTION);
981
+ expect(
982
+ mockCandidateStateRepository.saveAnnouncedRunningSubAgentLabels,
983
+ ).not.toHaveBeenCalled();
984
+ });
985
+ });
986
+
659
987
  it('sends to multiple sessions sequentially with a stagger delay between sends', async () => {
660
988
  mockSnapshotProvider.getSnapshot.mockResolvedValue(
661
989
  snapshotWithSessions([GITHUB_SESSION_ALPHA, GITHUB_SESSION_BRAVO]),