github-issue-tower-defence-management 1.122.10 → 1.122.12

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 (27) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js +12 -50
  3. package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js.map +1 -1
  4. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +32 -3
  5. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
  6. package/bin/domain/usecases/IssueRejectionEvaluator.js +56 -12
  7. package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
  8. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +14 -34
  9. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.test.ts +42 -213
  12. package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.ts +11 -83
  13. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +68 -5
  14. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +43 -3
  15. package/src/domain/usecases/IssueRejectionEvaluator.test.ts +154 -0
  16. package/src/domain/usecases/IssueRejectionEvaluator.ts +60 -14
  17. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +74 -127
  18. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +15 -51
  19. package/src/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.ts +0 -8
  20. package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts +0 -10
  21. package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts.map +1 -1
  22. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
  23. package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
  24. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +0 -1
  25. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  26. package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts +0 -8
  27. package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts.map +1 -1
@@ -388,6 +388,160 @@ describe('IssueRejectionEvaluator', () => {
388
388
  });
389
389
  });
390
390
 
391
+ describe('transient getOpenPullRequest failure containment', () => {
392
+ let warnSpy: jest.SpyInstance;
393
+
394
+ beforeEach(() => {
395
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
396
+ });
397
+
398
+ afterEach(() => {
399
+ warnSpy.mockRestore();
400
+ });
401
+
402
+ it('should not throw and should return no rejections when getOpenPullRequest rejects for a PR item (isPr=true)', async () => {
403
+ mockIssueRepository.getOpenPullRequest.mockRejectedValue(
404
+ new Error(
405
+ 'GraphQL errors: [{"message":"Something went wrong while executing your query"}]',
406
+ ),
407
+ );
408
+
409
+ const result = await evaluator.evaluate({
410
+ url: 'https://github.com/user/repo/pull/10',
411
+ labels: [],
412
+ isPr: true,
413
+ });
414
+
415
+ expect(result.rejections).toHaveLength(0);
416
+ expect(result.approvedPrUrl).toBeNull();
417
+ });
418
+
419
+ it('should log one warning line containing the PR URL and error message when getOpenPullRequest rejects for a PR item', async () => {
420
+ mockIssueRepository.getOpenPullRequest.mockRejectedValue(
421
+ new Error('Something went wrong while executing your query'),
422
+ );
423
+
424
+ await evaluator.evaluate({
425
+ url: 'https://github.com/user/repo/pull/10',
426
+ labels: [],
427
+ isPr: true,
428
+ });
429
+
430
+ expect(warnSpy).toHaveBeenCalledTimes(1);
431
+ expect(warnSpy).toHaveBeenCalledWith(
432
+ expect.stringContaining('https://github.com/user/repo/pull/10'),
433
+ );
434
+ expect(warnSpy).toHaveBeenCalledWith(
435
+ expect.stringContaining(
436
+ 'Something went wrong while executing your query',
437
+ ),
438
+ );
439
+ });
440
+
441
+ it('should skip the failing URL, log a warning, and still evaluate the healthy PR in the same batch (prebuilt path)', async () => {
442
+ mockIssueRepository.getOpenPullRequest.mockImplementation(
443
+ (prUrl: string) =>
444
+ prUrl === 'https://github.com/user/repo/pull/8'
445
+ ? Promise.reject(
446
+ new Error('Something went wrong while executing your query'),
447
+ )
448
+ : Promise.resolve(createReadyPr(prUrl)),
449
+ );
450
+
451
+ const result = await evaluator.evaluate(
452
+ {
453
+ url: 'https://github.com/user/repo/issues/1',
454
+ labels: [],
455
+ isPr: false,
456
+ },
457
+ [],
458
+ {
459
+ relatedOpenPrUrls: [
460
+ 'https://github.com/user/repo/pull/8',
461
+ 'https://github.com/user/repo/pull/9',
462
+ ],
463
+ },
464
+ );
465
+
466
+ expect(warnSpy).toHaveBeenCalledTimes(1);
467
+ expect(warnSpy).toHaveBeenCalledWith(
468
+ expect.stringContaining('https://github.com/user/repo/pull/8'),
469
+ );
470
+ expect(result.rejections).toHaveLength(0);
471
+ expect(result.approvedPrUrl).toBe(
472
+ 'https://github.com/user/repo/pull/9',
473
+ );
474
+ });
475
+
476
+ it('should not reject with PULL_REQUEST_NOT_FOUND when the only related URL fails transiently (state unknown, skipped)', async () => {
477
+ mockIssueRepository.getOpenPullRequest.mockRejectedValue(
478
+ new Error('Something went wrong while executing your query'),
479
+ );
480
+
481
+ const result = await evaluator.evaluate(
482
+ {
483
+ url: 'https://github.com/user/repo/issues/1',
484
+ labels: [],
485
+ isPr: false,
486
+ },
487
+ [],
488
+ { relatedOpenPrUrls: ['https://github.com/user/repo/pull/8'] },
489
+ );
490
+
491
+ expect(warnSpy).toHaveBeenCalledTimes(1);
492
+ expect(result.rejections).toHaveLength(0);
493
+ expect(result.approvedPrUrl).toBeNull();
494
+ });
495
+
496
+ it('should still evaluate the healthy remaining PR normally when it has a rejection reason (draft) after another URL fails', async () => {
497
+ mockIssueRepository.getOpenPullRequest.mockImplementation(
498
+ (prUrl: string) =>
499
+ prUrl === 'https://github.com/user/repo/pull/8'
500
+ ? Promise.reject(
501
+ new Error('Something went wrong while executing your query'),
502
+ )
503
+ : Promise.resolve(createReadyPr(prUrl, { isDraft: true })),
504
+ );
505
+
506
+ const result = await evaluator.evaluate(
507
+ {
508
+ url: 'https://github.com/user/repo/issues/1',
509
+ labels: [],
510
+ isPr: false,
511
+ },
512
+ [],
513
+ {
514
+ relatedOpenPrUrls: [
515
+ 'https://github.com/user/repo/pull/8',
516
+ 'https://github.com/user/repo/pull/9',
517
+ ],
518
+ },
519
+ );
520
+
521
+ expect(result.rejections).toHaveLength(1);
522
+ expect(result.rejections[0].type).toBe('PULL_REQUEST_IS_DRAFT');
523
+ expect(result.approvedPrUrl).toBeNull();
524
+ });
525
+
526
+ it('should handle a non-Error rejection value without throwing', async () => {
527
+ mockIssueRepository.getOpenPullRequest.mockRejectedValue(
528
+ 'string failure',
529
+ );
530
+
531
+ const result = await evaluator.evaluate({
532
+ url: 'https://github.com/user/repo/pull/10',
533
+ labels: [],
534
+ isPr: true,
535
+ });
536
+
537
+ expect(warnSpy).toHaveBeenCalledTimes(1);
538
+ expect(warnSpy).toHaveBeenCalledWith(
539
+ expect.stringContaining('string failure'),
540
+ );
541
+ expect(result.rejections).toHaveLength(0);
542
+ });
543
+ });
544
+
391
545
  describe('change-target-must: label behavior', () => {
392
546
  const prUrl = 'https://github.com/user/repo/pull/1';
393
547
 
@@ -67,17 +67,36 @@ export class IssueRejectionEvaluator {
67
67
  !hasLabelAsLlmAgentName &&
68
68
  (categoryLabels.length <= 0 || categoryLabels.includes('category:e2e'))
69
69
  ) {
70
- const prsToCheck = issue.isPr
71
- ? await this.resolveOpenPrsForPrItem(issue.url)
72
- : options.relatedOpenPrUrls != null
73
- ? await this.resolveOpenPrsFromUrls(options.relatedOpenPrUrls)
74
- : await this.issueRepository.findRelatedOpenPRs(issue.url);
70
+ let prsToCheck: RelatedPullRequest[];
71
+ let anyPrResolutionFailed = false;
72
+ if (issue.isPr) {
73
+ const resolved = await this.resolveOpenPrsForPrItem(issue.url);
74
+ if (resolved === null) {
75
+ // getOpenPullRequest failed transiently: the PR's state is unknown
76
+ // for this cycle, so skip it without emitting any rejection.
77
+ return { rejections, approvedPrUrl };
78
+ }
79
+ prsToCheck = resolved;
80
+ } else if (options.relatedOpenPrUrls != null) {
81
+ const resolved = await this.resolveOpenPrsFromUrls(
82
+ options.relatedOpenPrUrls,
83
+ );
84
+ prsToCheck = resolved.prs;
85
+ anyPrResolutionFailed = resolved.anyResolutionFailed;
86
+ } else {
87
+ prsToCheck = await this.issueRepository.findRelatedOpenPRs(issue.url);
88
+ }
75
89
 
76
90
  if (prsToCheck.length <= 0) {
77
- rejections.push({
78
- type: 'PULL_REQUEST_NOT_FOUND',
79
- detail: 'PULL_REQUEST_NOT_FOUND',
80
- });
91
+ if (!anyPrResolutionFailed) {
92
+ rejections.push({
93
+ type: 'PULL_REQUEST_NOT_FOUND',
94
+ detail: 'PULL_REQUEST_NOT_FOUND',
95
+ });
96
+ }
97
+ // When a resolution failed and no PR resolved, the related PR state is
98
+ // unknown for this cycle; emit no rejection so a transient GraphQL
99
+ // error cannot cause a false PULL_REQUEST_NOT_FOUND revert.
81
100
  } else if (prsToCheck.length > 1) {
82
101
  rejections.push({
83
102
  type: 'MULTIPLE_PULL_REQUESTS_FOUND',
@@ -164,10 +183,21 @@ export class IssueRejectionEvaluator {
164
183
  return { rejections, approvedPrUrl };
165
184
  };
166
185
 
186
+ // Returns null when getOpenPullRequest throws (e.g. a transient GitHub
187
+ // GraphQL server error): the PR's state is unknown for this cycle and the
188
+ // caller skips it instead of letting the error abort the schedule cycle.
167
189
  private resolveOpenPrsForPrItem = async (
168
190
  prUrl: string,
169
- ): Promise<RelatedPullRequest[]> => {
170
- const pr = await this.issueRepository.getOpenPullRequest(prUrl);
191
+ ): Promise<RelatedPullRequest[] | null> => {
192
+ let pr: RelatedPullRequest | null;
193
+ try {
194
+ pr = await this.issueRepository.getOpenPullRequest(prUrl);
195
+ } catch (error) {
196
+ console.warn(
197
+ `IssueRejectionEvaluator: getOpenPullRequest failed, skipping PR for this cycle. prUrl: ${prUrl} error: ${error instanceof Error ? error.message : String(error)}`,
198
+ );
199
+ return null;
200
+ }
171
201
  if (pr === null) {
172
202
  return [];
173
203
  }
@@ -180,18 +210,34 @@ export class IssueRejectionEvaluator {
180
210
  // so the resulting set is equivalent while avoiding the per-issue timeline
181
211
  // query. Duplicate URLs are de-duplicated to mirror findRelatedOpenPRs, which
182
212
  // collapses duplicates via its internal Map keyed by PR URL.
213
+ //
214
+ // When getOpenPullRequest throws for a URL (e.g. a transient GitHub GraphQL
215
+ // server error), that PR is logged and skipped for this cycle while the
216
+ // remaining URLs are still resolved; anyResolutionFailed reports whether at
217
+ // least one URL failed so the caller can avoid treating an unknown state as
218
+ // PULL_REQUEST_NOT_FOUND.
183
219
  private resolveOpenPrsFromUrls = async (
184
220
  prUrls: string[],
185
- ): Promise<RelatedPullRequest[]> => {
221
+ ): Promise<{ prs: RelatedPullRequest[]; anyResolutionFailed: boolean }> => {
186
222
  const uniquePrUrls = Array.from(new Set(prUrls));
187
223
  const resolvedPrs: RelatedPullRequest[] = [];
224
+ let anyResolutionFailed = false;
188
225
  for (const prUrl of uniquePrUrls) {
189
- const pr = await this.issueRepository.getOpenPullRequest(prUrl);
226
+ let pr: RelatedPullRequest | null;
227
+ try {
228
+ pr = await this.issueRepository.getOpenPullRequest(prUrl);
229
+ } catch (error) {
230
+ console.warn(
231
+ `IssueRejectionEvaluator: getOpenPullRequest failed, skipping PR for this cycle. prUrl: ${prUrl} error: ${error instanceof Error ? error.message : String(error)}`,
232
+ );
233
+ anyResolutionFailed = true;
234
+ continue;
235
+ }
190
236
  if (pr !== null) {
191
237
  resolvedPrs.push(pr);
192
238
  }
193
239
  }
194
- return resolvedPrs;
240
+ return { prs: resolvedPrs, anyResolutionFailed };
195
241
  };
196
242
 
197
243
  private extractChangeTargetMustPaths = (labels: string[]): string[] => {
@@ -167,12 +167,6 @@ describe('NotifySilentLiveSessionsUseCase', () => {
167
167
  .fn()
168
168
  .mockResolvedValue(everyNameRecentSet()),
169
169
  saveCandidateSessionNames: jest.fn().mockResolvedValue(undefined),
170
- loadAnnouncedRunningSubAgentLabels: jest
171
- .fn()
172
- .mockResolvedValue(new Set<string>()),
173
- saveAnnouncedRunningSubAgentLabels: jest
174
- .fn()
175
- .mockResolvedValue(undefined),
176
170
  };
177
171
  mockMessageComposer = {
178
172
  composeMainStalledSection: jest
@@ -573,7 +567,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
573
567
  const subAgents: SubAgentActivity[] = [
574
568
  {
575
569
  label: 'sub-process-1',
576
- silentSeconds: 10,
570
+ silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
577
571
  runningSeconds: 600,
578
572
  waitingOnExternalProcess: false,
579
573
  },
@@ -588,11 +582,33 @@ describe('NotifySilentLiveSessionsUseCase', () => {
588
582
  });
589
583
 
590
584
  expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith({
591
- idleSubAgents: [],
585
+ idleSubAgents: subAgents,
592
586
  longRunningSubAgents: subAgents,
593
587
  });
594
588
  });
595
589
 
590
+ it('suppresses the long-running section for a sub-agent that produced output recently even past the running threshold', async () => {
591
+ setupLiveInteractiveSession(GITHUB_SESSION);
592
+ const subAgents: SubAgentActivity[] = [
593
+ {
594
+ label: 'sub-process-1',
595
+ silentSeconds: 10,
596
+ runningSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
597
+ waitingOnExternalProcess: false,
598
+ },
599
+ ];
600
+ mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
601
+ new Map([[GITHUB_SESSION, subAgents]]),
602
+ );
603
+
604
+ await useCase.run(runParams());
605
+
606
+ expect(mockMessageComposer.composeSubAgentSection).not.toHaveBeenCalled();
607
+ expect(
608
+ mockNotificationRepository.sendSelfCheckNotification,
609
+ ).not.toHaveBeenCalled();
610
+ });
611
+
596
612
  it('excludes an owner-handover spawn from selection so no notification is sent', async () => {
597
613
  mockSnapshotProvider.getSnapshot.mockResolvedValue({
598
614
  sessions: [{ sessionName: 'aw-host', panePids: [100] }],
@@ -764,15 +780,21 @@ describe('NotifySilentLiveSessionsUseCase', () => {
764
780
  runningSeconds: 60,
765
781
  waitingOnExternalProcess: true,
766
782
  });
767
- const longRunningSubAgent = (
783
+ const quietLongRunningSubAgent = (
768
784
  label: string,
769
785
  waitingOnExternalProcess: boolean,
770
786
  ): SubAgentActivity => ({
771
787
  label,
772
- silentSeconds: 30,
788
+ silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
773
789
  runningSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
774
790
  waitingOnExternalProcess,
775
791
  });
792
+ const producingLongRunningSubAgent = (label: string): SubAgentActivity => ({
793
+ label,
794
+ silentSeconds: 30,
795
+ runningSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
796
+ waitingOnExternalProcess: false,
797
+ });
776
798
 
777
799
  const setupSubAgents = (subAgents: SubAgentActivity[]): void => {
778
800
  setupLiveInteractiveSession(GITHUB_SESSION);
@@ -817,69 +839,19 @@ describe('NotifySilentLiveSessionsUseCase', () => {
817
839
  ).toHaveBeenCalledTimes(2);
818
840
  });
819
841
 
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
- );
842
+ it('never selects a waiting sub-agent for the long-running section even when quiet past both thresholds', async () => {
843
+ setupSubAgents([quietLongRunningSubAgent('sub-process-1', true)]);
867
844
 
868
845
  await useCase.run(runParams());
869
846
 
847
+ expect(mockMessageComposer.composeSubAgentSection).not.toHaveBeenCalled();
870
848
  expect(
871
849
  mockNotificationRepository.sendSelfCheckNotification,
872
850
  ).not.toHaveBeenCalled();
873
- expect(
874
- mockCandidateStateRepository.saveAnnouncedRunningSubAgentLabels,
875
- ).not.toHaveBeenCalled();
876
851
  });
877
852
 
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
- );
853
+ it('never selects a long-running sub-agent that produced output within the silent threshold', async () => {
854
+ setupSubAgents([producingLongRunningSubAgent('sub-process-1')]);
883
855
 
884
856
  await useCase.run(runParams());
885
857
 
@@ -889,98 +861,73 @@ describe('NotifySilentLiveSessionsUseCase', () => {
889
861
  ).not.toHaveBeenCalled();
890
862
  });
891
863
 
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
- );
864
+ it('includes a quiet long-running sub-agent in both the idle and long-running sections', async () => {
865
+ setupSubAgents([quietLongRunningSubAgent('sub-process-1', false)]);
900
866
 
901
867
  await useCase.run(runParams());
902
868
 
903
869
  expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith({
904
- idleSubAgents: [],
905
- longRunningSubAgents: [longRunningSubAgent('sub-process-2', false)],
870
+ idleSubAgents: [quietLongRunningSubAgent('sub-process-1', false)],
871
+ longRunningSubAgents: [
872
+ quietLongRunningSubAgent('sub-process-1', false),
873
+ ],
906
874
  });
907
875
  expect(
908
- mockCandidateStateRepository.saveAnnouncedRunningSubAgentLabels,
909
- ).toHaveBeenCalledWith({
910
- sessionName: GITHUB_SESSION,
911
- labels: ['sub-process-1', 'sub-process-2'],
912
- now,
913
- });
876
+ mockNotificationRepository.sendSelfCheckNotification,
877
+ ).toHaveBeenCalledWith(GITHUB_SESSION, SUBAGENT_SECTION);
914
878
  });
915
879
 
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
- );
880
+ it('keeps notifying a quiet long-running sub-agent on every cycle while the condition holds', async () => {
881
+ setupSubAgents([quietLongRunningSubAgent('sub-process-1', false)]);
921
882
 
883
+ await useCase.run(runParams());
922
884
  await useCase.run(runParams());
923
885
 
924
886
  expect(
925
- mockCandidateStateRepository.saveAnnouncedRunningSubAgentLabels,
926
- ).toHaveBeenCalledWith({
927
- sessionName: GITHUB_SESSION,
928
- labels: [],
929
- now,
887
+ mockNotificationRepository.sendSelfCheckNotification,
888
+ ).toHaveBeenCalledTimes(2);
889
+ expect(
890
+ mockMessageComposer.composeSubAgentSection,
891
+ ).toHaveBeenNthCalledWith(2, {
892
+ idleSubAgents: [quietLongRunningSubAgent('sub-process-1', false)],
893
+ longRunningSubAgents: [
894
+ quietLongRunningSubAgent('sub-process-1', false),
895
+ ],
930
896
  });
931
897
  });
932
898
 
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
- },
899
+ it('defers a first-cycle long-running candidate until it persists into the next cycle', async () => {
900
+ setupSubAgents([quietLongRunningSubAgent('sub-process-1', false)]);
901
+ mockCandidateStateRepository.loadRecentCandidateSessionNames.mockResolvedValue(
902
+ new Set<string>(),
944
903
  );
945
904
 
946
905
  await useCase.run(runParams());
906
+
947
907
  expect(
948
908
  mockNotificationRepository.sendSelfCheckNotification,
949
- ).toHaveBeenCalledTimes(1);
909
+ ).not.toHaveBeenCalled();
910
+ expect(
911
+ mockCandidateStateRepository.saveCandidateSessionNames,
912
+ ).toHaveBeenCalledWith({ sessionNames: [GITHUB_SESSION], now });
913
+ });
950
914
 
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
- );
915
+ it('stops notifying once the long-running sub-agent drops out of the activity snapshot', async () => {
916
+ setupSubAgents([quietLongRunningSubAgent('sub-process-1', false)]);
967
917
 
918
+ await useCase.run(runParams());
968
919
  expect(
969
920
  mockNotificationRepository.sendSelfCheckNotification,
970
921
  ).toHaveBeenCalledTimes(1);
971
- });
972
-
973
- it('does not touch the announced-label record for a main-only reminder', async () => {
974
- setupSilentMainSession(GITHUB_SESSION);
975
922
 
923
+ mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
924
+ new Map<string, SubAgentActivity[]>(),
925
+ );
976
926
  await useCase.run(runParams());
977
927
 
978
928
  expect(
979
929
  mockNotificationRepository.sendSelfCheckNotification,
980
- ).toHaveBeenCalledWith(GITHUB_SESSION, MAIN_STALLED_SECTION);
981
- expect(
982
- mockCandidateStateRepository.saveAnnouncedRunningSubAgentLabels,
983
- ).not.toHaveBeenCalled();
930
+ ).toHaveBeenCalledTimes(1);
984
931
  });
985
932
  });
986
933
 
@@ -60,8 +60,6 @@ export type HubTaskStatusResolver = Pick<IssueRepository, 'getIssueByUrl'>;
60
60
  type NotifyCandidate = {
61
61
  sessionName: string;
62
62
  message: string;
63
- newlyAnnouncedRunningLabels: string[];
64
- retainedAnnouncedRunningLabels: string[];
65
63
  };
66
64
 
67
65
  export class NotifySilentLiveSessionsUseCase {
@@ -144,7 +142,7 @@ export class NotifySilentLiveSessionsUseCase {
144
142
 
145
143
  const candidates: NotifyCandidate[] = [];
146
144
  for (const sessionSnapshot of snapshots) {
147
- const candidate = await this.composeCandidate(sessionSnapshot, params);
145
+ const candidate = this.composeCandidate(sessionSnapshot, params);
148
146
  if (candidate !== null) {
149
147
  candidates.push(candidate);
150
148
  }
@@ -198,16 +196,6 @@ export class NotifySilentLiveSessionsUseCase {
198
196
  );
199
197
  sentCount += 1;
200
198
  console.log(`Notified ${candidate.sessionName}.`);
201
- if (candidate.newlyAnnouncedRunningLabels.length > 0) {
202
- await this.candidateStateRepository.saveAnnouncedRunningSubAgentLabels({
203
- sessionName: candidate.sessionName,
204
- labels: [
205
- ...candidate.retainedAnnouncedRunningLabels,
206
- ...candidate.newlyAnnouncedRunningLabels,
207
- ],
208
- now: params.now,
209
- });
210
- }
211
199
  }
212
200
  };
213
201
 
@@ -381,7 +369,7 @@ export class NotifySilentLiveSessionsUseCase {
381
369
  });
382
370
  };
383
371
 
384
- private composeCandidate = async (
372
+ private composeCandidate = (
385
373
  snapshot: LiveSessionActivitySnapshot,
386
374
  thresholds: {
387
375
  mainSilentThresholdSeconds: number;
@@ -390,7 +378,7 @@ export class NotifySilentLiveSessionsUseCase {
390
378
  subAgentRunningThresholdSeconds: number;
391
379
  now: Date;
392
380
  },
393
- ): Promise<NotifyCandidate | null> => {
381
+ ): NotifyCandidate | null => {
394
382
  const sections: string[] = [];
395
383
 
396
384
  const mainSilentSeconds = snapshot.mainSilentSeconds;
@@ -421,20 +409,24 @@ export class NotifySilentLiveSessionsUseCase {
421
409
  !subAgent.waitingOnExternalProcess &&
422
410
  subAgent.silentSeconds >= thresholds.subAgentSilentThresholdSeconds,
423
411
  );
412
+ // The long-running advisory is gated on output recency, mirroring the
413
+ // idle branch: a sub-agent that produced output recently is working, no
414
+ // matter how long it has been running, so it is never selected. Only a
415
+ // sub-agent that is BOTH long-running and quiet (and not waiting on a
416
+ // live external process) qualifies, and it is re-selected on EVERY cycle
417
+ // while the condition holds — there is intentionally no fire-once state
418
+ // and no time-window suppression, matching the idle-branch semantics.
424
419
  const longRunningSubAgents = snapshot.subAgents.filter(
425
420
  (subAgent) =>
426
- subAgent.runningSeconds >= thresholds.subAgentRunningThresholdSeconds,
427
- );
428
- const retainedAnnouncedRunningLabels =
429
- await this.reconcileAnnouncedRunningLabels(snapshot, thresholds.now);
430
- const newlyLongRunningSubAgents = longRunningSubAgents.filter(
431
- (subAgent) => !retainedAnnouncedRunningLabels.includes(subAgent.label),
421
+ !subAgent.waitingOnExternalProcess &&
422
+ subAgent.runningSeconds >= thresholds.subAgentRunningThresholdSeconds &&
423
+ subAgent.silentSeconds >= thresholds.subAgentSilentThresholdSeconds,
432
424
  );
433
- if (idleSubAgents.length > 0 || newlyLongRunningSubAgents.length > 0) {
425
+ if (idleSubAgents.length > 0 || longRunningSubAgents.length > 0) {
434
426
  sections.push(
435
427
  this.messageComposer.composeSubAgentSection({
436
428
  idleSubAgents,
437
- longRunningSubAgents: newlyLongRunningSubAgents,
429
+ longRunningSubAgents,
438
430
  }),
439
431
  );
440
432
  }
@@ -445,34 +437,6 @@ export class NotifySilentLiveSessionsUseCase {
445
437
  return {
446
438
  sessionName: snapshot.sessionName,
447
439
  message: sections.join('\n\n'),
448
- newlyAnnouncedRunningLabels: newlyLongRunningSubAgents.map(
449
- (subAgent) => subAgent.label,
450
- ),
451
- retainedAnnouncedRunningLabels,
452
440
  };
453
441
  };
454
-
455
- private reconcileAnnouncedRunningLabels = async (
456
- snapshot: LiveSessionActivitySnapshot,
457
- now: Date,
458
- ): Promise<string[]> => {
459
- const announcedLabels =
460
- await this.candidateStateRepository.loadAnnouncedRunningSubAgentLabels({
461
- sessionName: snapshot.sessionName,
462
- });
463
- const currentLabels = new Set(
464
- snapshot.subAgents.map((subAgent) => subAgent.label),
465
- );
466
- const retainedLabels = Array.from(announcedLabels).filter((label) =>
467
- currentLabels.has(label),
468
- );
469
- if (retainedLabels.length !== announcedLabels.size) {
470
- await this.candidateStateRepository.saveAnnouncedRunningSubAgentLabels({
471
- sessionName: snapshot.sessionName,
472
- labels: retainedLabels,
473
- now,
474
- });
475
- }
476
- return retainedLabels;
477
- };
478
442
  }