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.
- package/CHANGELOG.md +14 -0
- package/README.md +11 -7
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +38 -12
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +7 -2
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
- package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +44 -11
- package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
- package/bin/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.js +128 -0
- package/bin/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.js.map +1 -0
- package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +27 -7
- package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +65 -17
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
- package/bin/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +29 -8
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +14 -4
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +13 -0
- package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +81 -28
- package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +91 -19
- package/src/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.test.ts +251 -0
- package/src/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.ts +121 -0
- package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +91 -15
- package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +54 -11
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +252 -9
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +111 -20
- package/src/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.ts +20 -0
- package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +9 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -0
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
- package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +8 -4
- package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
- package/types/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.d.ts +20 -0
- package/types/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.d.ts.map +1 -0
- package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +2 -2
- package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +7 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.d.ts +19 -0
- package/types/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +5 -1
- 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('
|
|
103
|
-
const section = composer.composeSubAgentSection(
|
|
104
|
-
{ label: 'sub-process-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
expect(section).toContain('sub-process-
|
|
108
|
-
expect(section).toContain('
|
|
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('
|
|
111
|
-
expect(section).toContain('
|
|
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 {
|
|
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 = (
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
subAgent.silentSeconds,
|
|
47
|
-
)}, running for ${formatMinutes(subAgent.runningSeconds)}`,
|
|
87
|
+
subAgent.runningSeconds >= thresholds.subAgentRunningThresholdSeconds,
|
|
48
88
|
);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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).
|
|
789
|
-
|
|
790
|
-
|
|
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
|
|
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).
|
|
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
|
|