github-issue-tower-defence-management 1.139.0 → 1.140.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +8 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +50 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +5 -2
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
- package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js +31 -0
- package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js.map +1 -0
- package/bin/adapter/repositories/FileHandoverStateRepository.js +99 -0
- package/bin/adapter/repositories/FileHandoverStateRepository.js.map +1 -0
- package/bin/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.js +125 -0
- package/bin/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.js.map +1 -0
- package/bin/adapter/repositories/NodeProcessSignalRepository.js +44 -0
- package/bin/adapter/repositories/NodeProcessSignalRepository.js.map +1 -0
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js +67 -1
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
- package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js +206 -0
- package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js.map +1 -0
- package/bin/adapter/repositories/RateLimitSnapshotRepository.js +44 -0
- package/bin/adapter/repositories/RateLimitSnapshotRepository.js.map +1 -0
- package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js +4 -1
- package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js.map +1 -1
- package/bin/domain/entities/ClaudeHandoverSession.js +3 -0
- package/bin/domain/entities/ClaudeHandoverSession.js.map +1 -0
- package/bin/domain/entities/TokenExhaustionHandoverState.js +3 -0
- package/bin/domain/entities/TokenExhaustionHandoverState.js.map +1 -0
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +48 -1
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
- package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js +255 -0
- package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/ProcessSignalRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/ProcessSignalRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +37 -0
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +30 -0
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +8 -0
- package/src/adapter/entry-points/handlers/tokenExhaustionHandover.ts +79 -0
- package/src/adapter/repositories/FileHandoverStateRepository.test.ts +114 -0
- package/src/adapter/repositories/FileHandoverStateRepository.ts +76 -0
- package/src/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.test.ts +127 -0
- package/src/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.ts +108 -0
- package/src/adapter/repositories/NodeProcessSignalRepository.test.ts +82 -0
- package/src/adapter/repositories/NodeProcessSignalRepository.ts +47 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +132 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.ts +100 -0
- package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.test.ts +194 -0
- package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.ts +208 -0
- package/src/adapter/repositories/RateLimitSnapshotRepository.ts +50 -0
- package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +23 -2
- package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.ts +5 -1
- package/src/domain/entities/ClaudeHandoverSession.ts +11 -0
- package/src/domain/entities/TokenExhaustionHandoverState.ts +8 -0
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +218 -8
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +60 -0
- package/src/domain/usecases/TokenExhaustionHandoverUseCase.test.ts +696 -0
- package/src/domain/usecases/TokenExhaustionHandoverUseCase.ts +413 -0
- package/src/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.ts +5 -0
- package/src/domain/usecases/adapter-interfaces/ProcessSignalRepository.ts +5 -0
- package/src/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.ts +10 -0
- package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +2 -0
- package/src/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.ts +6 -0
- package/src/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.ts +22 -0
- package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +2 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +1 -0
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts +14 -0
- package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts.map +1 -0
- package/types/adapter/repositories/FileHandoverStateRepository.d.ts +11 -0
- package/types/adapter/repositories/FileHandoverStateRepository.d.ts.map +1 -0
- package/types/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.d.ts +16 -0
- package/types/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.d.ts.map +1 -0
- package/types/adapter/repositories/NodeProcessSignalRepository.d.ts +8 -0
- package/types/adapter/repositories/NodeProcessSignalRepository.d.ts.map +1 -0
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +7 -1
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
- package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts +13 -0
- package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts.map +1 -0
- package/types/adapter/repositories/RateLimitSnapshotRepository.d.ts +9 -0
- package/types/adapter/repositories/RateLimitSnapshotRepository.d.ts.map +1 -0
- package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts.map +1 -1
- package/types/domain/entities/ClaudeHandoverSession.d.ts +10 -0
- package/types/domain/entities/ClaudeHandoverSession.d.ts.map +1 -0
- package/types/domain/entities/TokenExhaustionHandoverState.d.ts +8 -0
- package/types/domain/entities/TokenExhaustionHandoverState.d.ts.map +1 -0
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +3 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts +50 -0
- package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.d.ts +5 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/ProcessSignalRepository.d.ts +6 -0
- package/types/domain/usecases/adapter-interfaces/ProcessSignalRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.d.ts +11 -0
- package/types/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +2 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.d.ts +6 -0
- package/types/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.d.ts +21 -0
- package/types/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.d.ts.map +1 -0
|
@@ -13,7 +13,7 @@ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
|
|
|
13
13
|
|
|
14
14
|
describe('TmuxSilentSessionNotificationRepository', () => {
|
|
15
15
|
describe('sendSelfCheckNotification', () => {
|
|
16
|
-
it('
|
|
16
|
+
it('wraps the message in bracketed-paste framing, sends it literally, then submits it with Enter', async () => {
|
|
17
17
|
const runner = createMockRunner();
|
|
18
18
|
const repository = new TmuxSilentSessionNotificationRepository(runner);
|
|
19
19
|
|
|
@@ -29,7 +29,7 @@ describe('TmuxSilentSessionNotificationRepository', () => {
|
|
|
29
29
|
'-t',
|
|
30
30
|
'https_//github_com/owner/repo/issues/9',
|
|
31
31
|
'-l',
|
|
32
|
-
'self check message',
|
|
32
|
+
'\x1b[200~self check message\x1b[201~',
|
|
33
33
|
],
|
|
34
34
|
]);
|
|
35
35
|
expect(runner.runCommand.mock.calls[1]).toEqual([
|
|
@@ -38,6 +38,27 @@ describe('TmuxSilentSessionNotificationRepository', () => {
|
|
|
38
38
|
]);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
it('preserves an unchanged multi-line message body inside the bracketed-paste frame', async () => {
|
|
42
|
+
const runner = createMockRunner();
|
|
43
|
+
const repository = new TmuxSilentSessionNotificationRepository(runner);
|
|
44
|
+
const multiLineMessage = 'first line\nsecond line\n\nthird line';
|
|
45
|
+
|
|
46
|
+
await repository.sendSelfCheckNotification(
|
|
47
|
+
'https_//github_com/owner/repo/issues/9',
|
|
48
|
+
multiLineMessage,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const literalArgument = runner.runCommand.mock.calls[0][1][4];
|
|
52
|
+
expect(literalArgument.startsWith('\x1b[200~')).toBe(true);
|
|
53
|
+
expect(literalArgument.endsWith('\x1b[201~')).toBe(true);
|
|
54
|
+
expect(
|
|
55
|
+
literalArgument.slice('\x1b[200~'.length, -'\x1b[201~'.length),
|
|
56
|
+
).toBe(multiLineMessage);
|
|
57
|
+
const literalBytes = Buffer.from(literalArgument, 'utf8').toString('hex');
|
|
58
|
+
expect(literalBytes.startsWith('1b5b3230307e')).toBe(true);
|
|
59
|
+
expect(literalBytes.endsWith('1b5b3230317e')).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
41
62
|
it('throws when sending the message literally fails', async () => {
|
|
42
63
|
const runner = createMockRunner();
|
|
43
64
|
runner.runCommand.mockResolvedValueOnce({
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { SilentSessionNotificationRepository } from '../../domain/usecases/adapter-interfaces/SilentSessionNotificationRepository';
|
|
2
2
|
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
3
3
|
|
|
4
|
+
const BRACKETED_PASTE_START = '\x1b[200~';
|
|
5
|
+
const BRACKETED_PASTE_END = '\x1b[201~';
|
|
6
|
+
|
|
4
7
|
export class TmuxSilentSessionNotificationRepository implements SilentSessionNotificationRepository {
|
|
5
8
|
constructor(private readonly localCommandRunner: LocalCommandRunner) {}
|
|
6
9
|
|
|
@@ -8,12 +11,13 @@ export class TmuxSilentSessionNotificationRepository implements SilentSessionNot
|
|
|
8
11
|
sessionName: string,
|
|
9
12
|
message: string,
|
|
10
13
|
): Promise<void> => {
|
|
14
|
+
const framedMessage = `${BRACKETED_PASTE_START}${message}${BRACKETED_PASTE_END}`;
|
|
11
15
|
const literalResult = await this.localCommandRunner.runCommand('tmux', [
|
|
12
16
|
'send-keys',
|
|
13
17
|
'-t',
|
|
14
18
|
sessionName,
|
|
15
19
|
'-l',
|
|
16
|
-
|
|
20
|
+
framedMessage,
|
|
17
21
|
]);
|
|
18
22
|
if (literalResult.exitCode !== 0) {
|
|
19
23
|
throw new Error(
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ClaudeHandoverSessionKind =
|
|
2
|
+
'issueUrlLeader' | 'bareNameLeader' | 'implSubagent';
|
|
3
|
+
|
|
4
|
+
export type ClaudeHandoverSession = {
|
|
5
|
+
kind: ClaudeHandoverSessionKind;
|
|
6
|
+
pid: number;
|
|
7
|
+
token: string;
|
|
8
|
+
sessionName: string | null;
|
|
9
|
+
name: string | null;
|
|
10
|
+
issueUrl: string | null;
|
|
11
|
+
};
|
|
@@ -21,6 +21,7 @@ import { OwnerCallStatusProvider } from './adapter-interfaces/OwnerCallStatusPro
|
|
|
21
21
|
import { RefusalTailStatusProvider } from './adapter-interfaces/RefusalTailStatusProvider';
|
|
22
22
|
import { SilentSessionNotificationRepository } from './adapter-interfaces/SilentSessionNotificationRepository';
|
|
23
23
|
import { SilentSessionCandidateStateRepository } from './adapter-interfaces/SilentSessionCandidateStateRepository';
|
|
24
|
+
import { SilentSessionNotifiedStateRepository } from './adapter-interfaces/SilentSessionNotifiedStateRepository';
|
|
24
25
|
import { SilentSessionHubTaskStatusCacheRepository } from './adapter-interfaces/SilentSessionHubTaskStatusCacheRepository';
|
|
25
26
|
import { SilentSessionMessageComposer } from './adapter-interfaces/SilentSessionMessageComposer';
|
|
26
27
|
import { Sleeper } from './adapter-interfaces/Sleeper';
|
|
@@ -50,6 +51,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
50
51
|
let mockOwnerCallStatusProvider: Mocked<OwnerCallStatusProvider>;
|
|
51
52
|
let mockNotificationRepository: Mocked<SilentSessionNotificationRepository>;
|
|
52
53
|
let mockCandidateStateRepository: Mocked<SilentSessionCandidateStateRepository>;
|
|
54
|
+
let mockNotifiedStateRepository: Mocked<SilentSessionNotifiedStateRepository>;
|
|
53
55
|
let mockMessageComposer: Mocked<SilentSessionMessageComposer>;
|
|
54
56
|
let mockSleeper: Mocked<Sleeper>;
|
|
55
57
|
let mockHubTaskStatusResolver: Mocked<HubTaskStatusResolver>;
|
|
@@ -169,6 +171,12 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
169
171
|
.mockResolvedValue(everyNameRecentSet()),
|
|
170
172
|
saveCandidateSessionNames: jest.fn().mockResolvedValue(undefined),
|
|
171
173
|
};
|
|
174
|
+
mockNotifiedStateRepository = {
|
|
175
|
+
loadRecentNotifiedSessionNames: jest
|
|
176
|
+
.fn()
|
|
177
|
+
.mockResolvedValue(new Set<string>()),
|
|
178
|
+
saveNotifiedSessionNames: jest.fn().mockResolvedValue(undefined),
|
|
179
|
+
};
|
|
172
180
|
mockMessageComposer = {
|
|
173
181
|
composeMainStalledSection: jest
|
|
174
182
|
.fn()
|
|
@@ -201,6 +209,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
201
209
|
mockOwnerCallStatusProvider,
|
|
202
210
|
mockNotificationRepository,
|
|
203
211
|
mockCandidateStateRepository,
|
|
212
|
+
mockNotifiedStateRepository,
|
|
204
213
|
mockMessageComposer,
|
|
205
214
|
mockSleeper,
|
|
206
215
|
mockHubTaskStatusResolver,
|
|
@@ -209,6 +218,20 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
209
218
|
);
|
|
210
219
|
});
|
|
211
220
|
|
|
221
|
+
// Simulates the on-disk fire-once latch across the per-cycle fresh monitor
|
|
222
|
+
// process: load returns the currently persisted set, save overwrites it.
|
|
223
|
+
const wireStatefulNotifiedLatch = (): void => {
|
|
224
|
+
let persistedNotifiedSessionNames = new Set<string>();
|
|
225
|
+
mockNotifiedStateRepository.loadRecentNotifiedSessionNames.mockImplementation(
|
|
226
|
+
async () => new Set(persistedNotifiedSessionNames),
|
|
227
|
+
);
|
|
228
|
+
mockNotifiedStateRepository.saveNotifiedSessionNames.mockImplementation(
|
|
229
|
+
async ({ sessionNames }) => {
|
|
230
|
+
persistedNotifiedSessionNames = new Set(sessionNames);
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
};
|
|
234
|
+
|
|
212
235
|
const issueFor = (overrides: Partial<Issue>): Issue => ({
|
|
213
236
|
nameWithOwner: 'HiromiShikata/repo',
|
|
214
237
|
number: 42,
|
|
@@ -706,7 +729,8 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
706
729
|
).not.toHaveBeenCalled();
|
|
707
730
|
});
|
|
708
731
|
|
|
709
|
-
it('
|
|
732
|
+
it('notifies a persistent stall only once per silent episode instead of every cycle', async () => {
|
|
733
|
+
wireStatefulNotifiedLatch();
|
|
710
734
|
setupLiveInteractiveSession(GITHUB_SESSION);
|
|
711
735
|
mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
|
|
712
736
|
[
|
|
@@ -719,18 +743,60 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
719
743
|
],
|
|
720
744
|
);
|
|
721
745
|
|
|
746
|
+
await useCase.run(runParams());
|
|
722
747
|
await useCase.run(runParams());
|
|
723
748
|
await useCase.run(runParams());
|
|
724
749
|
|
|
725
750
|
expect(
|
|
726
751
|
mockNotificationRepository.sendSelfCheckNotification,
|
|
727
|
-
).toHaveBeenCalledTimes(
|
|
752
|
+
).toHaveBeenCalledTimes(1);
|
|
753
|
+
expect(
|
|
754
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
755
|
+
).toHaveBeenCalledWith(GITHUB_SESSION, MAIN_STALLED_SECTION);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('re-notifies a session after its silent episode resolves and a new one begins', async () => {
|
|
759
|
+
wireStatefulNotifiedLatch();
|
|
760
|
+
setupLiveInteractiveSession(GITHUB_SESSION);
|
|
761
|
+
const stalled = [
|
|
762
|
+
{
|
|
763
|
+
sessionName: GITHUB_SESSION,
|
|
764
|
+
lastOutputEpochSeconds:
|
|
765
|
+
nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
|
|
766
|
+
hasInProgressToolCall: false,
|
|
767
|
+
},
|
|
768
|
+
];
|
|
769
|
+
const active = [
|
|
770
|
+
{
|
|
771
|
+
sessionName: GITHUB_SESSION,
|
|
772
|
+
lastOutputEpochSeconds: nowEpochSeconds,
|
|
773
|
+
hasInProgressToolCall: false,
|
|
774
|
+
},
|
|
775
|
+
];
|
|
776
|
+
|
|
777
|
+
mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
|
|
778
|
+
stalled,
|
|
779
|
+
);
|
|
780
|
+
await useCase.run(runParams());
|
|
781
|
+
expect(
|
|
782
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
783
|
+
).toHaveBeenCalledTimes(1);
|
|
784
|
+
|
|
785
|
+
mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
|
|
786
|
+
active,
|
|
787
|
+
);
|
|
788
|
+
await useCase.run(runParams());
|
|
728
789
|
expect(
|
|
729
790
|
mockNotificationRepository.sendSelfCheckNotification,
|
|
730
|
-
).
|
|
791
|
+
).toHaveBeenCalledTimes(1);
|
|
792
|
+
|
|
793
|
+
mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
|
|
794
|
+
stalled,
|
|
795
|
+
);
|
|
796
|
+
await useCase.run(runParams());
|
|
731
797
|
expect(
|
|
732
798
|
mockNotificationRepository.sendSelfCheckNotification,
|
|
733
|
-
).
|
|
799
|
+
).toHaveBeenCalledTimes(2);
|
|
734
800
|
});
|
|
735
801
|
|
|
736
802
|
describe('two-consecutive-cycle debounce', () => {
|
|
@@ -832,6 +898,147 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
832
898
|
});
|
|
833
899
|
});
|
|
834
900
|
|
|
901
|
+
describe('fire-once latch and input-state gating', () => {
|
|
902
|
+
const setupSilentGithubSession = (): void => {
|
|
903
|
+
setupLiveInteractiveSession(GITHUB_SESSION);
|
|
904
|
+
mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
|
|
905
|
+
[
|
|
906
|
+
{
|
|
907
|
+
sessionName: GITHUB_SESSION,
|
|
908
|
+
lastOutputEpochSeconds:
|
|
909
|
+
nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
|
|
910
|
+
hasInProgressToolCall: false,
|
|
911
|
+
},
|
|
912
|
+
],
|
|
913
|
+
);
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
const idleSubAgent = (label: string): SubAgentActivity => ({
|
|
917
|
+
label,
|
|
918
|
+
silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
|
|
919
|
+
runningSeconds: 60,
|
|
920
|
+
waitingOnExternalProcess: false,
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it('loads the fire-once latch using the configured recency window', async () => {
|
|
924
|
+
setupSilentGithubSession();
|
|
925
|
+
|
|
926
|
+
await useCase.run(
|
|
927
|
+
runParams({ candidateDebounceRecencyWindowSeconds: 900 }),
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
expect(
|
|
931
|
+
mockNotifiedStateRepository.loadRecentNotifiedSessionNames,
|
|
932
|
+
).toHaveBeenCalledWith({ now, recencyWindowSeconds: 900 });
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it('persists the sent session in the fire-once latch on a send cycle', async () => {
|
|
936
|
+
setupSilentGithubSession();
|
|
937
|
+
|
|
938
|
+
await useCase.run(runParams());
|
|
939
|
+
|
|
940
|
+
expect(
|
|
941
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
942
|
+
).toHaveBeenCalledWith(GITHUB_SESSION, MAIN_STALLED_SECTION);
|
|
943
|
+
expect(
|
|
944
|
+
mockNotifiedStateRepository.saveNotifiedSessionNames,
|
|
945
|
+
).toHaveBeenCalledWith({ sessionNames: [GITHUB_SESSION], now });
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
it('does not re-send but keeps the latch for a session already notified this episode', async () => {
|
|
949
|
+
setupSilentGithubSession();
|
|
950
|
+
mockNotifiedStateRepository.loadRecentNotifiedSessionNames.mockResolvedValue(
|
|
951
|
+
new Set([GITHUB_SESSION]),
|
|
952
|
+
);
|
|
953
|
+
|
|
954
|
+
await useCase.run(runParams());
|
|
955
|
+
|
|
956
|
+
expect(
|
|
957
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
958
|
+
).not.toHaveBeenCalled();
|
|
959
|
+
expect(
|
|
960
|
+
mockNotifiedStateRepository.saveNotifiedSessionNames,
|
|
961
|
+
).toHaveBeenCalledWith({ sessionNames: [GITHUB_SESSION], now });
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it('prunes the fire-once latch for a previously notified session that is no longer a candidate', async () => {
|
|
965
|
+
mockNotifiedStateRepository.loadRecentNotifiedSessionNames.mockResolvedValue(
|
|
966
|
+
new Set([GITHUB_SESSION]),
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
await useCase.run(runParams());
|
|
970
|
+
|
|
971
|
+
expect(
|
|
972
|
+
mockNotifiedStateRepository.saveNotifiedSessionNames,
|
|
973
|
+
).toHaveBeenCalledWith({ sessionNames: [], now });
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it('does not inject into a session whose main REPL has an in-progress tool call even when a sub-agent advisory qualifies', async () => {
|
|
977
|
+
setupLiveInteractiveSession(GITHUB_SESSION);
|
|
978
|
+
mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
|
|
979
|
+
[
|
|
980
|
+
{
|
|
981
|
+
sessionName: GITHUB_SESSION,
|
|
982
|
+
lastOutputEpochSeconds: nowEpochSeconds,
|
|
983
|
+
hasInProgressToolCall: true,
|
|
984
|
+
},
|
|
985
|
+
],
|
|
986
|
+
);
|
|
987
|
+
mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
|
|
988
|
+
new Map([[GITHUB_SESSION, [idleSubAgent('sub-process-1')]]]),
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
await useCase.run(runParams());
|
|
992
|
+
|
|
993
|
+
expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalled();
|
|
994
|
+
expect(
|
|
995
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
996
|
+
).not.toHaveBeenCalled();
|
|
997
|
+
expect(
|
|
998
|
+
mockNotifiedStateRepository.saveNotifiedSessionNames,
|
|
999
|
+
).toHaveBeenCalledWith({ sessionNames: [], now });
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it('delivers the deferred reminder once the main REPL becomes input-ready', async () => {
|
|
1003
|
+
wireStatefulNotifiedLatch();
|
|
1004
|
+
setupLiveInteractiveSession(GITHUB_SESSION);
|
|
1005
|
+
mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
|
|
1006
|
+
new Map([[GITHUB_SESSION, [idleSubAgent('sub-process-1')]]]),
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
|
|
1010
|
+
[
|
|
1011
|
+
{
|
|
1012
|
+
sessionName: GITHUB_SESSION,
|
|
1013
|
+
lastOutputEpochSeconds: nowEpochSeconds,
|
|
1014
|
+
hasInProgressToolCall: true,
|
|
1015
|
+
},
|
|
1016
|
+
],
|
|
1017
|
+
);
|
|
1018
|
+
await useCase.run(runParams());
|
|
1019
|
+
expect(
|
|
1020
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
1021
|
+
).not.toHaveBeenCalled();
|
|
1022
|
+
|
|
1023
|
+
mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
|
|
1024
|
+
[
|
|
1025
|
+
{
|
|
1026
|
+
sessionName: GITHUB_SESSION,
|
|
1027
|
+
lastOutputEpochSeconds: nowEpochSeconds,
|
|
1028
|
+
hasInProgressToolCall: false,
|
|
1029
|
+
},
|
|
1030
|
+
],
|
|
1031
|
+
);
|
|
1032
|
+
await useCase.run(runParams());
|
|
1033
|
+
expect(
|
|
1034
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
1035
|
+
).toHaveBeenCalledTimes(1);
|
|
1036
|
+
expect(
|
|
1037
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
1038
|
+
).toHaveBeenCalledWith(GITHUB_SESSION, SUBAGENT_SECTION);
|
|
1039
|
+
});
|
|
1040
|
+
});
|
|
1041
|
+
|
|
835
1042
|
describe('sub-agent state-based reminder judgment', () => {
|
|
836
1043
|
const hungSubAgent = (label: string): SubAgentActivity => ({
|
|
837
1044
|
label,
|
|
@@ -893,7 +1100,8 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
893
1100
|
).toHaveBeenCalledWith(GITHUB_SESSION, SUBAGENT_SECTION);
|
|
894
1101
|
});
|
|
895
1102
|
|
|
896
|
-
it('
|
|
1103
|
+
it('notifies a hung sub-agent only once per episode instead of every cycle', async () => {
|
|
1104
|
+
wireStatefulNotifiedLatch();
|
|
897
1105
|
setupSubAgents([hungSubAgent('sub-process-1')]);
|
|
898
1106
|
|
|
899
1107
|
await useCase.run(runParams());
|
|
@@ -901,7 +1109,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
901
1109
|
|
|
902
1110
|
expect(
|
|
903
1111
|
mockNotificationRepository.sendSelfCheckNotification,
|
|
904
|
-
).toHaveBeenCalledTimes(
|
|
1112
|
+
).toHaveBeenCalledTimes(1);
|
|
905
1113
|
});
|
|
906
1114
|
|
|
907
1115
|
it('never selects a waiting sub-agent for the long-running section even when quiet past both thresholds', async () => {
|
|
@@ -942,7 +1150,8 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
942
1150
|
).toHaveBeenCalledWith(GITHUB_SESSION, SUBAGENT_SECTION);
|
|
943
1151
|
});
|
|
944
1152
|
|
|
945
|
-
it('
|
|
1153
|
+
it('notifies a quiet long-running sub-agent only once per episode while still evaluating it every cycle', async () => {
|
|
1154
|
+
wireStatefulNotifiedLatch();
|
|
946
1155
|
setupSubAgents([quietLongRunningSubAgent('sub-process-1', false)]);
|
|
947
1156
|
|
|
948
1157
|
await useCase.run(runParams());
|
|
@@ -950,7 +1159,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
950
1159
|
|
|
951
1160
|
expect(
|
|
952
1161
|
mockNotificationRepository.sendSelfCheckNotification,
|
|
953
|
-
).toHaveBeenCalledTimes(
|
|
1162
|
+
).toHaveBeenCalledTimes(1);
|
|
954
1163
|
expect(
|
|
955
1164
|
mockMessageComposer.composeSubAgentSection,
|
|
956
1165
|
).toHaveBeenNthCalledWith(2, {
|
|
@@ -1626,6 +1835,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
1626
1835
|
mockOwnerCallStatusProvider,
|
|
1627
1836
|
mockNotificationRepository,
|
|
1628
1837
|
mockCandidateStateRepository,
|
|
1838
|
+
mockNotifiedStateRepository,
|
|
1629
1839
|
mockMessageComposer,
|
|
1630
1840
|
mockSleeper,
|
|
1631
1841
|
mockHubTaskStatusResolver,
|
|
@@ -9,6 +9,7 @@ import { SessionSubAgentActivityRepository } from './adapter-interfaces/SessionS
|
|
|
9
9
|
import { SilentSessionMessageComposer } from './adapter-interfaces/SilentSessionMessageComposer';
|
|
10
10
|
import { SilentSessionNotificationRepository } from './adapter-interfaces/SilentSessionNotificationRepository';
|
|
11
11
|
import { SilentSessionCandidateStateRepository } from './adapter-interfaces/SilentSessionCandidateStateRepository';
|
|
12
|
+
import { SilentSessionNotifiedStateRepository } from './adapter-interfaces/SilentSessionNotifiedStateRepository';
|
|
12
13
|
import { SilentSessionHubTaskStatusCacheRepository } from './adapter-interfaces/SilentSessionHubTaskStatusCacheRepository';
|
|
13
14
|
import { Sleeper } from './adapter-interfaces/Sleeper';
|
|
14
15
|
import { IssueRepository } from './adapter-interfaces/IssueRepository';
|
|
@@ -70,6 +71,7 @@ type NotifyCandidate = {
|
|
|
70
71
|
sessionName: string;
|
|
71
72
|
message: string;
|
|
72
73
|
sectionLabels: string[];
|
|
74
|
+
mainInputBusy: boolean;
|
|
73
75
|
};
|
|
74
76
|
|
|
75
77
|
export class NotifySilentLiveSessionsUseCase {
|
|
@@ -84,6 +86,7 @@ export class NotifySilentLiveSessionsUseCase {
|
|
|
84
86
|
private readonly ownerCallStatusProvider: OwnerCallStatusProvider,
|
|
85
87
|
private readonly notificationRepository: SilentSessionNotificationRepository,
|
|
86
88
|
private readonly candidateStateRepository: SilentSessionCandidateStateRepository,
|
|
89
|
+
private readonly notifiedStateRepository: SilentSessionNotifiedStateRepository,
|
|
87
90
|
private readonly messageComposer: SilentSessionMessageComposer,
|
|
88
91
|
private readonly sleeper: Sleeper,
|
|
89
92
|
private readonly hubTaskStatusResolver: HubTaskStatusResolver | null = null,
|
|
@@ -185,8 +188,44 @@ export class NotifySilentLiveSessionsUseCase {
|
|
|
185
188
|
`Silent live session notification: ${debouncedCandidates.length} debounced candidate(s) of ${candidates.length} current candidate(s) across ${interactiveSessions.length} interactive session(s); ${suppressedFirstCycleCount} first-cycle candidate(s) deferred until they persist into the next cycle.`,
|
|
186
189
|
);
|
|
187
190
|
|
|
191
|
+
// Fire-once latch: the set of sessions already reminded during their
|
|
192
|
+
// current silent episode. A session that is still a candidate this cycle
|
|
193
|
+
// and was already reminded is NOT re-injected, so a continuous silent
|
|
194
|
+
// episode produces exactly one reminder instead of one every schedule
|
|
195
|
+
// cycle. The latch is keyed by the globally-unique session name and
|
|
196
|
+
// persisted across the per-cycle fresh monitor process on disk.
|
|
197
|
+
const currentCandidateSessionNames = new Set(
|
|
198
|
+
candidates.map((candidate) => candidate.sessionName),
|
|
199
|
+
);
|
|
200
|
+
const previouslyNotifiedSessionNames =
|
|
201
|
+
await this.notifiedStateRepository.loadRecentNotifiedSessionNames({
|
|
202
|
+
now: params.now,
|
|
203
|
+
recencyWindowSeconds: params.candidateDebounceRecencyWindowSeconds,
|
|
204
|
+
});
|
|
205
|
+
|
|
188
206
|
let sentCount = 0;
|
|
207
|
+
const notifiedThisCycleSessionNames: string[] = [];
|
|
189
208
|
for (const candidate of debouncedCandidates) {
|
|
209
|
+
if (previouslyNotifiedSessionNames.has(candidate.sessionName)) {
|
|
210
|
+
console.log(
|
|
211
|
+
`Skipping ${candidate.sessionName}: the current silent-episode reminder was already delivered; not re-injecting until the condition resolves and re-arises.`,
|
|
212
|
+
);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// Input-state gating: a session whose main REPL is mid-turn — currently
|
|
216
|
+
// running an in-progress tool call, which includes waiting on a
|
|
217
|
+
// long-running sub-agent — cannot accept an injected prompt cleanly, so
|
|
218
|
+
// the reminder is deferred rather than piled into a busy input box. The
|
|
219
|
+
// session stays a candidate, so it is reminded once it becomes
|
|
220
|
+
// input-ready. A tool call pending past
|
|
221
|
+
// IN_PROGRESS_TOOL_CALL_MAX_SUPPRESS_SECONDS is treated as hung and no
|
|
222
|
+
// longer counts as busy (see composeCandidate).
|
|
223
|
+
if (candidate.mainInputBusy) {
|
|
224
|
+
console.log(
|
|
225
|
+
`Skipping ${candidate.sessionName}: main REPL is not in an input-accepting state (in-progress tool call); deferring reminder until it is idle.`,
|
|
226
|
+
);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
190
229
|
if (
|
|
191
230
|
!(await this.isHubTaskActive(
|
|
192
231
|
candidate.sessionName,
|
|
@@ -205,6 +244,7 @@ export class NotifySilentLiveSessionsUseCase {
|
|
|
205
244
|
candidate.message,
|
|
206
245
|
);
|
|
207
246
|
sentCount += 1;
|
|
247
|
+
notifiedThisCycleSessionNames.push(candidate.sessionName);
|
|
208
248
|
// One line per send, grep-stable on the `Notified ` prefix: the
|
|
209
249
|
// ISO-8601 UTC timestamp disambiguates concurrent schedule runs and
|
|
210
250
|
// the section list records what the message actually contained.
|
|
@@ -212,6 +252,25 @@ export class NotifySilentLiveSessionsUseCase {
|
|
|
212
252
|
`Notified ${candidate.sessionName} at=${params.now.toISOString()} sections=[${candidate.sectionLabels.join(',')}]`,
|
|
213
253
|
);
|
|
214
254
|
}
|
|
255
|
+
|
|
256
|
+
// Persist the latch for the next cycle: keep every already-latched session
|
|
257
|
+
// that is STILL a candidate (refreshing its timestamp so a continuous
|
|
258
|
+
// episode stays latched) plus the sessions notified this cycle, and prune
|
|
259
|
+
// any latched session that is no longer a candidate so its episode ends and
|
|
260
|
+
// a later re-qualification fires a fresh reminder.
|
|
261
|
+
const retainedNotifiedSessionNames = [
|
|
262
|
+
...previouslyNotifiedSessionNames,
|
|
263
|
+
].filter((sessionName) => currentCandidateSessionNames.has(sessionName));
|
|
264
|
+
const notifiedSessionNamesToPersist = Array.from(
|
|
265
|
+
new Set([
|
|
266
|
+
...retainedNotifiedSessionNames,
|
|
267
|
+
...notifiedThisCycleSessionNames,
|
|
268
|
+
]),
|
|
269
|
+
);
|
|
270
|
+
await this.notifiedStateRepository.saveNotifiedSessionNames({
|
|
271
|
+
sessionNames: notifiedSessionNamesToPersist,
|
|
272
|
+
now: params.now,
|
|
273
|
+
});
|
|
215
274
|
};
|
|
216
275
|
|
|
217
276
|
private isHubTaskActive = async (
|
|
@@ -488,6 +547,7 @@ export class NotifySilentLiveSessionsUseCase {
|
|
|
488
547
|
sessionName: snapshot.sessionName,
|
|
489
548
|
message: sections.join('\n\n'),
|
|
490
549
|
sectionLabels,
|
|
550
|
+
mainInputBusy: suppressedByInProgressToolCall,
|
|
491
551
|
};
|
|
492
552
|
};
|
|
493
553
|
}
|