github-issue-tower-defence-management 1.117.10 → 1.118.1

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 (45) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +11 -7
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +38 -12
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +7 -2
  6. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  7. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +44 -11
  8. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  9. package/bin/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.js +128 -0
  10. package/bin/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.js.map +1 -0
  11. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +27 -7
  12. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  13. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +65 -17
  14. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  15. package/bin/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.js +3 -0
  16. package/bin/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.js.map +1 -0
  17. package/package.json +1 -1
  18. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +29 -8
  19. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +14 -4
  20. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +13 -0
  21. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +81 -28
  22. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +91 -19
  23. package/src/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.test.ts +251 -0
  24. package/src/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.ts +121 -0
  25. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +91 -15
  26. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +54 -11
  27. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +252 -9
  28. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +111 -20
  29. package/src/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.ts +20 -0
  30. package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +9 -1
  31. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  32. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -0
  33. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  34. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +8 -4
  35. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  36. package/types/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.d.ts +20 -0
  37. package/types/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.d.ts.map +1 -0
  38. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +2 -2
  39. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  40. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +7 -1
  41. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  42. package/types/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.d.ts +19 -0
  43. package/types/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.d.ts.map +1 -0
  44. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +5 -1
  45. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts.map +1 -1
@@ -1,6 +1,11 @@
1
1
  import { DefaultSilentSessionMessageComposer } from './DefaultSilentSessionMessageComposer';
2
2
  import { SILENT_SESSION_REMINDER_SENTINEL } from './silentSessionReminderSentinel';
3
3
 
4
+ const THRESHOLDS = {
5
+ subAgentSilentThresholdSeconds: 300,
6
+ subAgentRunningThresholdSeconds: 900,
7
+ };
8
+
4
9
  describe('DefaultSilentSessionMessageComposer', () => {
5
10
  const composer = new DefaultSilentSessionMessageComposer();
6
11
 
@@ -10,9 +15,10 @@ describe('DefaultSilentSessionMessageComposer', () => {
10
15
  });
11
16
 
12
17
  it('embeds the reminder sentinel in the sub-agent section', () => {
13
- const section = composer.composeSubAgentSection([
14
- { label: 'sub-process-1', silentSeconds: 360, runningSeconds: 1200 },
15
- ]);
18
+ const section = composer.composeSubAgentSection(
19
+ [{ label: 'sub-process-1', silentSeconds: 360, runningSeconds: 1200 }],
20
+ THRESHOLDS,
21
+ );
16
22
  expect(section).toContain(SILENT_SESSION_REMINDER_SENTINEL);
17
23
  });
18
24
 
@@ -99,23 +105,93 @@ describe('DefaultSilentSessionMessageComposer', () => {
99
105
  expect(section).not.toContain('""');
100
106
  });
101
107
 
102
- it('lists each sub-agent with its silent and running minutes', () => {
103
- const section = composer.composeSubAgentSection([
104
- { label: 'sub-process-1', silentSeconds: 360, runningSeconds: 1200 },
105
- { label: 'sub-process-2', silentSeconds: 0, runningSeconds: 960 },
106
- ]);
107
- expect(section).toContain('sub-process-1');
108
- expect(section).toContain('silent for 6m');
108
+ it('emits a distinct idle message for a sub-agent that is only output-idle', () => {
109
+ const section = composer.composeSubAgentSection(
110
+ [{ label: 'sub-process-idle', silentSeconds: 360, runningSeconds: 60 }],
111
+ THRESHOLDS,
112
+ );
113
+ expect(section).toContain('sub-process-idle');
114
+ expect(section).toContain('no output for 6m');
115
+ expect(section).toContain('may be stalled');
116
+ expect(section).toContain('restart, hand off, or replace it');
117
+ expect(section).toContain('waiting on an external dependency');
118
+ expect(section).not.toContain('infinite loop');
119
+ });
120
+
121
+ it('emits a distinct long-running message for a sub-agent that has only run too long', () => {
122
+ const section = composer.composeSubAgentSection(
123
+ [
124
+ {
125
+ label: 'sub-process-long',
126
+ silentSeconds: 30,
127
+ runningSeconds: 1200,
128
+ },
129
+ ],
130
+ THRESHOLDS,
131
+ );
132
+ expect(section).toContain('sub-process-long');
109
133
  expect(section).toContain('running for 20m');
110
- expect(section).toContain('sub-process-2');
111
- expect(section).toContain('running for 16m');
134
+ expect(section).toContain('infinite loop');
135
+ expect(section).toContain('too large');
136
+ expect(section).toContain('not making forward progress');
137
+ expect(section).toContain(
138
+ 'do not dismiss it merely because it produced output recently',
139
+ );
140
+ expect(section).toContain(
141
+ 'break the task down, restart, hand off, or replace',
142
+ );
143
+ });
144
+
145
+ it('does not foreground the short idle time in the long-running message', () => {
146
+ const section = composer.composeSubAgentSection(
147
+ [
148
+ {
149
+ label: 'sub-process-long',
150
+ silentSeconds: 30,
151
+ runningSeconds: 1200,
152
+ },
153
+ ],
154
+ THRESHOLDS,
155
+ );
156
+ expect(section).not.toContain('no output for');
157
+ expect(section).not.toContain('silent for');
158
+ });
159
+
160
+ it('emits both distinct messages for a sub-agent matching both conditions, kept separate', () => {
161
+ const section = composer.composeSubAgentSection(
162
+ [{ label: 'sub-process-both', silentSeconds: 360, runningSeconds: 1200 }],
163
+ THRESHOLDS,
164
+ );
165
+ expect(section).toContain('no output for 6m');
166
+ expect(section).toContain('may be stalled');
167
+ expect(section).toContain('running for 20m');
168
+ expect(section).toContain('infinite loop');
169
+ const sentinelOccurrences =
170
+ section.split(SILENT_SESSION_REMINDER_SENTINEL).length - 1;
171
+ expect(sentinelOccurrences).toBe(2);
172
+ });
173
+
174
+ it('groups each sub-agent under the condition it matched', () => {
175
+ const section = composer.composeSubAgentSection(
176
+ [
177
+ { label: 'idle-only', silentSeconds: 360, runningSeconds: 60 },
178
+ { label: 'long-only', silentSeconds: 10, runningSeconds: 960 },
179
+ ],
180
+ THRESHOLDS,
181
+ );
182
+ const [idleSection, longRunningSection] = section.split('\n\n');
183
+ expect(idleSection).toContain('idle-only');
184
+ expect(idleSection).not.toContain('long-only');
185
+ expect(longRunningSection).toContain('long-only');
186
+ expect(longRunningSection).not.toContain('idle-only');
112
187
  });
113
188
 
114
189
  it('does not contain any host-specific or internal identifiers', () => {
115
190
  const mainSection = composer.composeMainStalledSection(600);
116
- const subSection = composer.composeSubAgentSection([
117
- { label: 'sub-process-1', silentSeconds: 360, runningSeconds: 1200 },
118
- ]);
191
+ const subSection = composer.composeSubAgentSection(
192
+ [{ label: 'sub-process-1', silentSeconds: 360, runningSeconds: 1200 }],
193
+ THRESHOLDS,
194
+ );
119
195
  const combined = `${mainSection}\n${subSection}`.toLowerCase();
120
196
  expect(combined).not.toContain('claude');
121
197
  expect(combined).not.toContain('take ownership');
@@ -1,5 +1,8 @@
1
1
  import { SubAgentActivity } from '../entities/LiveSessionActivitySnapshot';
2
- import { SilentSessionMessageComposer } from './adapter-interfaces/SilentSessionMessageComposer';
2
+ import {
3
+ SilentSessionMessageComposer,
4
+ SubAgentStallThresholds,
5
+ } from './adapter-interfaces/SilentSessionMessageComposer';
3
6
  import { SILENT_SESSION_REMINDER_SENTINEL } from './silentSessionReminderSentinel';
4
7
 
5
8
  const formatMinutes = (seconds: number): string => {
@@ -7,6 +10,38 @@ const formatMinutes = (seconds: number): string => {
7
10
  return `${minutes}m`;
8
11
  };
9
12
 
13
+ const composeIdleSubAgentSection = (
14
+ idleSubAgents: SubAgentActivity[],
15
+ ): string => {
16
+ const lines = idleSubAgents.map(
17
+ (subAgent) =>
18
+ `- ${subAgent.label}: no output for ${formatMinutes(
19
+ subAgent.silentSeconds,
20
+ )}`,
21
+ );
22
+ return [
23
+ `${SILENT_SESSION_REMINDER_SENTINEL} The following sub-process(es) have produced no output for several minutes and may be stalled:`,
24
+ ...lines,
25
+ 'Check each one. If it is stuck, take action (restart, hand off, or replace it). If it is legitimately waiting on an external dependency (continuous integration, an external API, or another process), let it continue.',
26
+ ].join('\n');
27
+ };
28
+
29
+ const composeLongRunningSubAgentSection = (
30
+ longRunningSubAgents: SubAgentActivity[],
31
+ ): string => {
32
+ const lines = longRunningSubAgents.map(
33
+ (subAgent) =>
34
+ `- ${subAgent.label}: running for ${formatMinutes(
35
+ subAgent.runningSeconds,
36
+ )}`,
37
+ );
38
+ return [
39
+ `${SILENT_SESSION_REMINDER_SENTINEL} The following sub-process(es) have been running longer than a task should normally take, which may mean an infinite loop, a task that is too large, or being stuck in an incorrect approach that is not making forward progress:`,
40
+ ...lines,
41
+ 'Verify each one is genuinely advancing toward completion; do not dismiss it merely because it produced output recently. If it is not progressing, intervene: break the task down, restart, hand off, or replace it.',
42
+ ].join('\n');
43
+ };
44
+
10
45
  const composeOwnerCallFormatGuidance = (
11
46
  ownerCallMarker: string | null,
12
47
  ): string => {
@@ -39,17 +74,25 @@ export class DefaultSilentSessionMessageComposer implements SilentSessionMessage
39
74
  return composeMainStalledMessage(mainSilentSeconds, this.ownerCallMarker);
40
75
  };
41
76
 
42
- composeSubAgentSection = (subAgents: SubAgentActivity[]): string => {
43
- const lines = subAgents.map(
77
+ composeSubAgentSection = (
78
+ subAgents: SubAgentActivity[],
79
+ thresholds: SubAgentStallThresholds,
80
+ ): string => {
81
+ const idleSubAgents = subAgents.filter(
82
+ (subAgent) =>
83
+ subAgent.silentSeconds >= thresholds.subAgentSilentThresholdSeconds,
84
+ );
85
+ const longRunningSubAgents = subAgents.filter(
44
86
  (subAgent) =>
45
- `- ${subAgent.label}: silent for ${formatMinutes(
46
- subAgent.silentSeconds,
47
- )}, running for ${formatMinutes(subAgent.runningSeconds)}`,
87
+ subAgent.runningSeconds >= thresholds.subAgentRunningThresholdSeconds,
48
88
  );
49
- return [
50
- `${SILENT_SESSION_REMINDER_SENTINEL} The following sub-processes have been silent or running for a long time:`,
51
- ...lines,
52
- 'If a sub-process is stalled, take action (restart, hand off, or replace it). If it is legitimately waiting on an external dependency (continuous integration, an external API, or another process), let it continue.',
53
- ].join('\n');
89
+ const sections: string[] = [];
90
+ if (idleSubAgents.length > 0) {
91
+ sections.push(composeIdleSubAgentSection(idleSubAgents));
92
+ }
93
+ if (longRunningSubAgents.length > 0) {
94
+ sections.push(composeLongRunningSubAgentSection(longRunningSubAgents));
95
+ }
96
+ return sections.join('\n\n');
54
97
  };
55
98
  }
@@ -8,6 +8,7 @@ import {
8
8
  DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
9
9
  DEFAULT_NOTIFICATION_STAGGER_SECONDS,
10
10
  DEFAULT_CANDIDATE_DEBOUNCE_RECENCY_WINDOW_SECONDS,
11
+ DEFAULT_HUB_TASK_STATUS_CACHE_TTL_SECONDS,
11
12
  } from './NotifySilentLiveSessionsUseCase';
12
13
  import { Issue } from '../entities/Issue';
13
14
  import { LiveSessionProcessSnapshotProvider } from './adapter-interfaces/LiveSessionProcessSnapshotProvider';
@@ -17,6 +18,7 @@ import { SessionSubAgentActivityRepository } from './adapter-interfaces/SessionS
17
18
  import { OwnerCallStatusProvider } from './adapter-interfaces/OwnerCallStatusProvider';
18
19
  import { SilentSessionNotificationRepository } from './adapter-interfaces/SilentSessionNotificationRepository';
19
20
  import { SilentSessionCandidateStateRepository } from './adapter-interfaces/SilentSessionCandidateStateRepository';
21
+ import { SilentSessionHubTaskStatusCacheRepository } from './adapter-interfaces/SilentSessionHubTaskStatusCacheRepository';
20
22
  import { SilentSessionMessageComposer } from './adapter-interfaces/SilentSessionMessageComposer';
21
23
  import { Sleeper } from './adapter-interfaces/Sleeper';
22
24
  import { SubAgentActivity } from '../entities/LiveSessionActivitySnapshot';
@@ -46,6 +48,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
46
48
  let mockMessageComposer: Mocked<SilentSessionMessageComposer>;
47
49
  let mockSleeper: Mocked<Sleeper>;
48
50
  let mockHubTaskStatusResolver: Mocked<HubTaskStatusResolver>;
51
+ let mockHubTaskStatusCacheRepository: Mocked<SilentSessionHubTaskStatusCacheRepository>;
49
52
  const now = new Date('2026-06-26T00:00:00Z');
50
53
  const nowEpochSeconds = Math.floor(now.getTime() / 1000);
51
54
  const GITHUB_SESSION = 'https_//github_com/HiromiShikata/repo/issues/42';
@@ -58,6 +61,8 @@ describe('NotifySilentLiveSessionsUseCase', () => {
58
61
  overrides?: Partial<{
59
62
  activeHubTaskStatus: string | null;
60
63
  candidateDebounceRecencyWindowSeconds: number;
64
+ hubTaskStatusCacheTtlSeconds: number;
65
+ now: Date;
61
66
  }>,
62
67
  ): {
63
68
  mainSilentThresholdSeconds: number;
@@ -66,6 +71,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
66
71
  staggerSeconds: number;
67
72
  candidateDebounceRecencyWindowSeconds: number;
68
73
  activeHubTaskStatus: string | null;
74
+ hubTaskStatusCacheTtlSeconds: number;
69
75
  now: Date;
70
76
  } => ({
71
77
  mainSilentThresholdSeconds: DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
@@ -75,6 +81,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
75
81
  candidateDebounceRecencyWindowSeconds:
76
82
  DEFAULT_CANDIDATE_DEBOUNCE_RECENCY_WINDOW_SECONDS,
77
83
  activeHubTaskStatus: null,
84
+ hubTaskStatusCacheTtlSeconds: DEFAULT_HUB_TASK_STATUS_CACHE_TTL_SECONDS,
78
85
  now,
79
86
  ...overrides,
80
87
  });
@@ -165,6 +172,10 @@ describe('NotifySilentLiveSessionsUseCase', () => {
165
172
  mockHubTaskStatusResolver = {
166
173
  getIssueByUrl: jest.fn().mockResolvedValue(null),
167
174
  };
175
+ mockHubTaskStatusCacheRepository = {
176
+ loadHubTaskStatus: jest.fn().mockResolvedValue(null),
177
+ saveHubTaskStatus: jest.fn().mockResolvedValue(undefined),
178
+ };
168
179
  useCase = new NotifySilentLiveSessionsUseCase(
169
180
  mockSnapshotProvider,
170
181
  mockTranscriptResolver,
@@ -176,6 +187,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
176
187
  mockMessageComposer,
177
188
  mockSleeper,
178
189
  mockHubTaskStatusResolver,
190
+ mockHubTaskStatusCacheRepository,
179
191
  );
180
192
  });
181
193
 
@@ -446,12 +458,46 @@ describe('NotifySilentLiveSessionsUseCase', () => {
446
458
 
447
459
  expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith(
448
460
  subAgents,
461
+ {
462
+ subAgentSilentThresholdSeconds:
463
+ DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
464
+ subAgentRunningThresholdSeconds:
465
+ DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
466
+ },
449
467
  );
450
468
  expect(
451
469
  mockNotificationRepository.sendSelfCheckNotification,
452
470
  ).toHaveBeenCalledWith(GITHUB_SESSION, SUBAGENT_SECTION);
453
471
  });
454
472
 
473
+ it('passes a configured running threshold through to the sub-agent section composer', async () => {
474
+ setupLiveInteractiveSession(GITHUB_SESSION);
475
+ const subAgents: SubAgentActivity[] = [
476
+ {
477
+ label: 'sub-process-1',
478
+ silentSeconds: 10,
479
+ runningSeconds: 600,
480
+ },
481
+ ];
482
+ mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
483
+ new Map([[GITHUB_SESSION, subAgents]]),
484
+ );
485
+
486
+ await useCase.run({
487
+ ...runParams(),
488
+ subAgentRunningThresholdSeconds: 600,
489
+ });
490
+
491
+ expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith(
492
+ subAgents,
493
+ {
494
+ subAgentSilentThresholdSeconds:
495
+ DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
496
+ subAgentRunningThresholdSeconds: 600,
497
+ },
498
+ );
499
+ });
500
+
455
501
  it('excludes an owner-handover spawn from selection so no notification is sent', async () => {
456
502
  mockSnapshotProvider.getSnapshot.mockResolvedValue({
457
503
  sessions: [{ sessionName: 'aw-host', panePids: [100] }],
@@ -770,14 +816,11 @@ describe('NotifySilentLiveSessionsUseCase', () => {
770
816
  warnSpy.mockRestore();
771
817
  });
772
818
 
773
- it('fails open without a warning when the hub task is not a resolvable tracked task', async () => {
819
+ it('fails open with a distinct no-cache warning when the hub task is not a resolvable tracked task and no cached status exists', async () => {
774
820
  setupSilentMainSession(HUB_TASK_SESSION);
775
821
  const warnSpy = jest
776
822
  .spyOn(console, 'warn')
777
823
  .mockImplementation(() => undefined);
778
- const logSpy = jest
779
- .spyOn(console, 'log')
780
- .mockImplementation(() => undefined);
781
824
  mockHubTaskStatusResolver.getIssueByUrl.mockResolvedValue(null);
782
825
 
783
826
  await useCase.run(runParams({ activeHubTaskStatus: ACTIVE_STATUS }));
@@ -785,9 +828,12 @@ describe('NotifySilentLiveSessionsUseCase', () => {
785
828
  expect(
786
829
  mockNotificationRepository.sendSelfCheckNotification,
787
830
  ).toHaveBeenCalledWith(HUB_TASK_SESSION, MAIN_STALLED_SECTION);
788
- expect(warnSpy).not.toHaveBeenCalled();
789
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('fail-open'));
790
- logSpy.mockRestore();
831
+ expect(warnSpy).toHaveBeenCalledWith(
832
+ expect.stringContaining('no cached status'),
833
+ );
834
+ expect(warnSpy).toHaveBeenCalledWith(
835
+ expect.stringContaining('fail-open'),
836
+ );
791
837
  warnSpy.mockRestore();
792
838
  });
793
839
 
@@ -839,7 +885,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
839
885
  ).not.toHaveBeenCalled();
840
886
  });
841
887
 
842
- it('fails open without throwing or warning for a real tmux session-name form whose repository is unresolvable', async () => {
888
+ it('fails open without throwing for a real tmux session-name form whose repository is unresolvable and has no cached status', async () => {
843
889
  const REAL_TMUX_SESSION =
844
890
  'https_//github_com/example-org/example-repo/issues/2350';
845
891
  const CANONICAL_ISSUE_URL =
@@ -860,7 +906,204 @@ describe('NotifySilentLiveSessionsUseCase', () => {
860
906
  expect(
861
907
  mockNotificationRepository.sendSelfCheckNotification,
862
908
  ).toHaveBeenCalledWith(REAL_TMUX_SESSION, MAIN_STALLED_SECTION);
863
- expect(warnSpy).not.toHaveBeenCalled();
909
+ expect(warnSpy).toHaveBeenCalledWith(
910
+ expect.stringContaining('no cached status'),
911
+ );
912
+ warnSpy.mockRestore();
913
+ });
914
+
915
+ it('suppresses a resolved CLOSED hub task and writes the resolved status to the cache', async () => {
916
+ setupSilentMainSession(HUB_TASK_SESSION);
917
+ mockHubTaskStatusResolver.getIssueByUrl.mockResolvedValue(
918
+ issueFor({
919
+ url: HUB_TASK_SESSION,
920
+ state: 'CLOSED',
921
+ status: ACTIVE_STATUS,
922
+ isClosed: true,
923
+ }),
924
+ );
925
+
926
+ await useCase.run(runParams({ activeHubTaskStatus: ACTIVE_STATUS }));
927
+
928
+ expect(
929
+ mockNotificationRepository.sendSelfCheckNotification,
930
+ ).not.toHaveBeenCalled();
931
+ expect(
932
+ mockHubTaskStatusCacheRepository.saveHubTaskStatus,
933
+ ).toHaveBeenCalledWith({
934
+ url: HUB_TASK_SESSION,
935
+ state: 'CLOSED',
936
+ status: ACTIVE_STATUS,
937
+ now,
938
+ });
939
+ });
940
+
941
+ it('suppresses a resolved Done (non-active project status) hub task', async () => {
942
+ setupSilentMainSession(HUB_TASK_SESSION);
943
+ mockHubTaskStatusResolver.getIssueByUrl.mockResolvedValue(
944
+ issueFor({ url: HUB_TASK_SESSION, state: 'OPEN', status: 'Done' }),
945
+ );
946
+
947
+ await useCase.run(runParams({ activeHubTaskStatus: ACTIVE_STATUS }));
948
+
949
+ expect(
950
+ mockNotificationRepository.sendSelfCheckNotification,
951
+ ).not.toHaveBeenCalled();
952
+ });
953
+
954
+ it('sends a resolved active hub task and writes its status to the cache', async () => {
955
+ setupSilentMainSession(HUB_TASK_SESSION);
956
+ mockHubTaskStatusResolver.getIssueByUrl.mockResolvedValue(
957
+ issueFor({
958
+ url: HUB_TASK_SESSION,
959
+ state: 'OPEN',
960
+ status: ACTIVE_STATUS,
961
+ }),
962
+ );
963
+
964
+ await useCase.run(runParams({ activeHubTaskStatus: ACTIVE_STATUS }));
965
+
966
+ expect(
967
+ mockNotificationRepository.sendSelfCheckNotification,
968
+ ).toHaveBeenCalledWith(HUB_TASK_SESSION, MAIN_STALLED_SECTION);
969
+ expect(
970
+ mockHubTaskStatusCacheRepository.saveHubTaskStatus,
971
+ ).toHaveBeenCalledWith({
972
+ url: HUB_TASK_SESSION,
973
+ state: 'OPEN',
974
+ status: ACTIVE_STATUS,
975
+ now,
976
+ });
977
+ });
978
+
979
+ it('decides from a fresh cached entry without consulting the resolver', async () => {
980
+ setupSilentMainSession(HUB_TASK_SESSION);
981
+ mockHubTaskStatusCacheRepository.loadHubTaskStatus.mockResolvedValue({
982
+ url: HUB_TASK_SESSION,
983
+ state: 'OPEN',
984
+ status: ACTIVE_STATUS,
985
+ recordedEpochSeconds: nowEpochSeconds - 60,
986
+ });
987
+
988
+ await useCase.run(
989
+ runParams({
990
+ activeHubTaskStatus: ACTIVE_STATUS,
991
+ hubTaskStatusCacheTtlSeconds: 300,
992
+ }),
993
+ );
994
+
995
+ expect(mockHubTaskStatusResolver.getIssueByUrl).not.toHaveBeenCalled();
996
+ expect(
997
+ mockHubTaskStatusCacheRepository.saveHubTaskStatus,
998
+ ).not.toHaveBeenCalled();
999
+ expect(
1000
+ mockNotificationRepository.sendSelfCheckNotification,
1001
+ ).toHaveBeenCalledWith(HUB_TASK_SESSION, MAIN_STALLED_SECTION);
1002
+ });
1003
+
1004
+ it('suppresses from a fresh cached closed entry without consulting the resolver', async () => {
1005
+ setupSilentMainSession(HUB_TASK_SESSION);
1006
+ mockHubTaskStatusCacheRepository.loadHubTaskStatus.mockResolvedValue({
1007
+ url: HUB_TASK_SESSION,
1008
+ state: 'CLOSED',
1009
+ status: ACTIVE_STATUS,
1010
+ recordedEpochSeconds: nowEpochSeconds - 60,
1011
+ });
1012
+
1013
+ await useCase.run(
1014
+ runParams({
1015
+ activeHubTaskStatus: ACTIVE_STATUS,
1016
+ hubTaskStatusCacheTtlSeconds: 300,
1017
+ }),
1018
+ );
1019
+
1020
+ expect(mockHubTaskStatusResolver.getIssueByUrl).not.toHaveBeenCalled();
1021
+ expect(
1022
+ mockNotificationRepository.sendSelfCheckNotification,
1023
+ ).not.toHaveBeenCalled();
1024
+ });
1025
+
1026
+ it('re-resolves when the cached entry is older than the configured time-to-live', async () => {
1027
+ setupSilentMainSession(HUB_TASK_SESSION);
1028
+ mockHubTaskStatusCacheRepository.loadHubTaskStatus.mockResolvedValue({
1029
+ url: HUB_TASK_SESSION,
1030
+ state: 'OPEN',
1031
+ status: ACTIVE_STATUS,
1032
+ recordedEpochSeconds: nowEpochSeconds - 600,
1033
+ });
1034
+ mockHubTaskStatusResolver.getIssueByUrl.mockResolvedValue(
1035
+ issueFor({
1036
+ url: HUB_TASK_SESSION,
1037
+ state: 'OPEN',
1038
+ status: ACTIVE_STATUS,
1039
+ }),
1040
+ );
1041
+
1042
+ await useCase.run(
1043
+ runParams({
1044
+ activeHubTaskStatus: ACTIVE_STATUS,
1045
+ hubTaskStatusCacheTtlSeconds: 300,
1046
+ }),
1047
+ );
1048
+
1049
+ expect(mockHubTaskStatusResolver.getIssueByUrl).toHaveBeenCalledWith(
1050
+ HUB_TASK_SESSION,
1051
+ );
1052
+ expect(
1053
+ mockNotificationRepository.sendSelfCheckNotification,
1054
+ ).toHaveBeenCalledWith(HUB_TASK_SESSION, MAIN_STALLED_SECTION);
1055
+ });
1056
+
1057
+ it('falls back to an expired cached active entry and still sends when live resolution returns null', async () => {
1058
+ setupSilentMainSession(HUB_TASK_SESSION);
1059
+ mockHubTaskStatusCacheRepository.loadHubTaskStatus.mockResolvedValue({
1060
+ url: HUB_TASK_SESSION,
1061
+ state: 'OPEN',
1062
+ status: ACTIVE_STATUS,
1063
+ recordedEpochSeconds: nowEpochSeconds - 600,
1064
+ });
1065
+ mockHubTaskStatusResolver.getIssueByUrl.mockResolvedValue(null);
1066
+
1067
+ await useCase.run(
1068
+ runParams({
1069
+ activeHubTaskStatus: ACTIVE_STATUS,
1070
+ hubTaskStatusCacheTtlSeconds: 300,
1071
+ }),
1072
+ );
1073
+
1074
+ expect(
1075
+ mockNotificationRepository.sendSelfCheckNotification,
1076
+ ).toHaveBeenCalledWith(HUB_TASK_SESSION, MAIN_STALLED_SECTION);
1077
+ });
1078
+
1079
+ it('falls back to an expired cached closed entry and suppresses when live resolution throws', async () => {
1080
+ setupSilentMainSession(HUB_TASK_SESSION);
1081
+ const warnSpy = jest
1082
+ .spyOn(console, 'warn')
1083
+ .mockImplementation(() => undefined);
1084
+ mockHubTaskStatusCacheRepository.loadHubTaskStatus.mockResolvedValue({
1085
+ url: HUB_TASK_SESSION,
1086
+ state: 'CLOSED',
1087
+ status: ACTIVE_STATUS,
1088
+ recordedEpochSeconds: nowEpochSeconds - 600,
1089
+ });
1090
+ mockHubTaskStatusResolver.getIssueByUrl.mockRejectedValue(
1091
+ new Error('GraphQL rate limit'),
1092
+ );
1093
+
1094
+ await useCase.run(
1095
+ runParams({
1096
+ activeHubTaskStatus: ACTIVE_STATUS,
1097
+ hubTaskStatusCacheTtlSeconds: 300,
1098
+ }),
1099
+ );
1100
+
1101
+ expect(
1102
+ mockNotificationRepository.sendSelfCheckNotification,
1103
+ ).not.toHaveBeenCalled();
1104
+ expect(warnSpy).toHaveBeenCalledWith(
1105
+ expect.stringContaining('expired cached status'),
1106
+ );
864
1107
  warnSpy.mockRestore();
865
1108
  });
866
1109