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.
Files changed (109) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +8 -2
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +50 -2
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +5 -2
  6. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js +31 -0
  8. package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js.map +1 -0
  9. package/bin/adapter/repositories/FileHandoverStateRepository.js +99 -0
  10. package/bin/adapter/repositories/FileHandoverStateRepository.js.map +1 -0
  11. package/bin/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.js +125 -0
  12. package/bin/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.js.map +1 -0
  13. package/bin/adapter/repositories/NodeProcessSignalRepository.js +44 -0
  14. package/bin/adapter/repositories/NodeProcessSignalRepository.js.map +1 -0
  15. package/bin/adapter/repositories/NodeTmuxSessionRepository.js +67 -1
  16. package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
  17. package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js +206 -0
  18. package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js.map +1 -0
  19. package/bin/adapter/repositories/RateLimitSnapshotRepository.js +44 -0
  20. package/bin/adapter/repositories/RateLimitSnapshotRepository.js.map +1 -0
  21. package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js +4 -1
  22. package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js.map +1 -1
  23. package/bin/domain/entities/ClaudeHandoverSession.js +3 -0
  24. package/bin/domain/entities/ClaudeHandoverSession.js.map +1 -0
  25. package/bin/domain/entities/TokenExhaustionHandoverState.js +3 -0
  26. package/bin/domain/entities/TokenExhaustionHandoverState.js.map +1 -0
  27. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +48 -1
  28. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  29. package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js +255 -0
  30. package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js.map +1 -0
  31. package/bin/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.js +3 -0
  32. package/bin/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.js.map +1 -0
  33. package/bin/domain/usecases/adapter-interfaces/ProcessSignalRepository.js +3 -0
  34. package/bin/domain/usecases/adapter-interfaces/ProcessSignalRepository.js.map +1 -0
  35. package/bin/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.js +3 -0
  36. package/bin/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.js.map +1 -0
  37. package/bin/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.js +3 -0
  38. package/bin/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.js.map +1 -0
  39. package/bin/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.js +3 -0
  40. package/bin/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.js.map +1 -0
  41. package/package.json +1 -1
  42. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +37 -0
  43. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +30 -0
  44. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +8 -0
  45. package/src/adapter/entry-points/handlers/tokenExhaustionHandover.ts +79 -0
  46. package/src/adapter/repositories/FileHandoverStateRepository.test.ts +114 -0
  47. package/src/adapter/repositories/FileHandoverStateRepository.ts +76 -0
  48. package/src/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.test.ts +127 -0
  49. package/src/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.ts +108 -0
  50. package/src/adapter/repositories/NodeProcessSignalRepository.test.ts +82 -0
  51. package/src/adapter/repositories/NodeProcessSignalRepository.ts +47 -0
  52. package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +132 -0
  53. package/src/adapter/repositories/NodeTmuxSessionRepository.ts +100 -0
  54. package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.test.ts +194 -0
  55. package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.ts +208 -0
  56. package/src/adapter/repositories/RateLimitSnapshotRepository.ts +50 -0
  57. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +23 -2
  58. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.ts +5 -1
  59. package/src/domain/entities/ClaudeHandoverSession.ts +11 -0
  60. package/src/domain/entities/TokenExhaustionHandoverState.ts +8 -0
  61. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +218 -8
  62. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +60 -0
  63. package/src/domain/usecases/TokenExhaustionHandoverUseCase.test.ts +696 -0
  64. package/src/domain/usecases/TokenExhaustionHandoverUseCase.ts +413 -0
  65. package/src/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.ts +5 -0
  66. package/src/domain/usecases/adapter-interfaces/ProcessSignalRepository.ts +5 -0
  67. package/src/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.ts +10 -0
  68. package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +2 -0
  69. package/src/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.ts +6 -0
  70. package/src/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.ts +22 -0
  71. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +2 -0
  72. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  73. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +1 -0
  74. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  75. package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts +14 -0
  76. package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts.map +1 -0
  77. package/types/adapter/repositories/FileHandoverStateRepository.d.ts +11 -0
  78. package/types/adapter/repositories/FileHandoverStateRepository.d.ts.map +1 -0
  79. package/types/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.d.ts +16 -0
  80. package/types/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.d.ts.map +1 -0
  81. package/types/adapter/repositories/NodeProcessSignalRepository.d.ts +8 -0
  82. package/types/adapter/repositories/NodeProcessSignalRepository.d.ts.map +1 -0
  83. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +7 -1
  84. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
  85. package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts +13 -0
  86. package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts.map +1 -0
  87. package/types/adapter/repositories/RateLimitSnapshotRepository.d.ts +9 -0
  88. package/types/adapter/repositories/RateLimitSnapshotRepository.d.ts.map +1 -0
  89. package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts.map +1 -1
  90. package/types/domain/entities/ClaudeHandoverSession.d.ts +10 -0
  91. package/types/domain/entities/ClaudeHandoverSession.d.ts.map +1 -0
  92. package/types/domain/entities/TokenExhaustionHandoverState.d.ts +8 -0
  93. package/types/domain/entities/TokenExhaustionHandoverState.d.ts.map +1 -0
  94. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +3 -1
  95. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  96. package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts +50 -0
  97. package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts.map +1 -0
  98. package/types/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.d.ts +5 -0
  99. package/types/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.d.ts.map +1 -0
  100. package/types/domain/usecases/adapter-interfaces/ProcessSignalRepository.d.ts +6 -0
  101. package/types/domain/usecases/adapter-interfaces/ProcessSignalRepository.d.ts.map +1 -0
  102. package/types/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.d.ts +11 -0
  103. package/types/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.d.ts.map +1 -0
  104. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +2 -0
  105. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
  106. package/types/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.d.ts +6 -0
  107. package/types/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.d.ts.map +1 -0
  108. package/types/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.d.ts +21 -0
  109. package/types/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.d.ts.map +1 -0
@@ -0,0 +1,696 @@
1
+ import {
2
+ TokenExhaustionHandoverUseCase,
3
+ DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE,
4
+ DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE_BARE_NAME_LEADER,
5
+ DEFAULT_TOKEN_EXHAUSTION_GRACE_PERIOD_SECONDS,
6
+ TOKEN_EXHAUSTION_SNAPSHOT_STALE_THRESHOLD_SECONDS,
7
+ TOKEN_EXHAUSTION_SNAPSHOT_HARD_STALE_THRESHOLD_SECONDS,
8
+ } from './TokenExhaustionHandoverUseCase';
9
+ import { ClaudeHandoverSession } from '../entities/ClaudeHandoverSession';
10
+ import { TokenExhaustionHandoverState } from '../entities/TokenExhaustionHandoverState';
11
+ import { ClaudeHandoverSessionRepository } from './adapter-interfaces/ClaudeHandoverSessionRepository';
12
+ import { ProcessSignalRepository } from './adapter-interfaces/ProcessSignalRepository';
13
+ import { TmuxSessionRepository } from './adapter-interfaces/TmuxSessionRepository';
14
+ import {
15
+ TokenModelWeeklyLimit,
16
+ TokenRateLimitSnapshot,
17
+ TokenRateLimitSnapshotRepository,
18
+ } from './adapter-interfaces/TokenRateLimitSnapshotRepository';
19
+
20
+ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
21
+
22
+ const TOKEN_EXHAUSTED = 'token-exhausted';
23
+ const TOKEN_FRESH = 'token-fresh';
24
+ const ISSUE_URL = 'https://github.com/owner/repo/issues/1';
25
+ const ISSUE_URL_SESSION = ISSUE_URL.replace(/[.:]/g, '_');
26
+ const BARE_NAME = 'app';
27
+ const IMPL_PID = 4242;
28
+ const LEADER_PID = 1111;
29
+
30
+ const now = new Date('2026-01-01T12:00:00Z');
31
+ const nowEpochSeconds = Math.floor(now.getTime() / 1000);
32
+
33
+ const snapshot = (
34
+ token: string,
35
+ overrides: Partial<TokenRateLimitSnapshot> = {},
36
+ ): TokenRateLimitSnapshot => ({
37
+ token,
38
+ name: `name-${token}`,
39
+ fiveHourUtilization: 0,
40
+ fiveHourReset: 0,
41
+ sevenDayUtilization: 0,
42
+ sevenDayReset: 0,
43
+ blocked: false,
44
+ rejected: false,
45
+ blockedUntilEpoch: 0,
46
+ modelWeeklyLimits: [],
47
+ lastUpdatedEpoch: nowEpochSeconds - 60,
48
+ ...overrides,
49
+ });
50
+
51
+ const issueUrlLeaderSession = (): ClaudeHandoverSession => ({
52
+ kind: 'issueUrlLeader',
53
+ pid: LEADER_PID,
54
+ token: TOKEN_EXHAUSTED,
55
+ sessionName: ISSUE_URL_SESSION,
56
+ name: ISSUE_URL,
57
+ issueUrl: ISSUE_URL,
58
+ });
59
+
60
+ const bareNameLeaderSession = (): ClaudeHandoverSession => ({
61
+ kind: 'bareNameLeader',
62
+ pid: LEADER_PID,
63
+ token: TOKEN_EXHAUSTED,
64
+ sessionName: BARE_NAME,
65
+ name: BARE_NAME,
66
+ issueUrl: null,
67
+ });
68
+
69
+ const implSubagentSession = (): ClaudeHandoverSession => ({
70
+ kind: 'implSubagent',
71
+ pid: IMPL_PID,
72
+ token: TOKEN_EXHAUSTED,
73
+ sessionName: null,
74
+ name: null,
75
+ issueUrl: ISSUE_URL,
76
+ });
77
+
78
+ const defaultInput = (
79
+ overrides: Partial<{
80
+ enabled: boolean;
81
+ issueUrlLeaderMessage: string;
82
+ bareNameLeaderMessage: string;
83
+ gracePeriodSeconds: number;
84
+ state: TokenExhaustionHandoverState;
85
+ now: Date;
86
+ }> = {},
87
+ ) => ({
88
+ enabled: true,
89
+ issueUrlLeaderMessage: DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE,
90
+ bareNameLeaderMessage:
91
+ DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE_BARE_NAME_LEADER,
92
+ gracePeriodSeconds: DEFAULT_TOKEN_EXHAUSTION_GRACE_PERIOD_SECONDS,
93
+ state: { entries: {} },
94
+ now,
95
+ ...overrides,
96
+ });
97
+
98
+ describe('TokenExhaustionHandoverUseCase', () => {
99
+ let useCase: TokenExhaustionHandoverUseCase;
100
+ let handoverSessionRepository: Mocked<
101
+ Pick<ClaudeHandoverSessionRepository, 'listHandoverSessions'>
102
+ >;
103
+ let snapshotRepository: Mocked<
104
+ Pick<TokenRateLimitSnapshotRepository, 'listSnapshots'>
105
+ >;
106
+ let tmuxSessionRepository: Mocked<
107
+ Pick<
108
+ TmuxSessionRepository,
109
+ | 'sendKeys'
110
+ | 'killSession'
111
+ | 'listLiveSessionNames'
112
+ | 'launchBareNameLeaderSession'
113
+ >
114
+ >;
115
+ let processSignalRepository: Mocked<ProcessSignalRepository>;
116
+
117
+ const exhaustedFiveHour = (): TokenModelWeeklyLimit[] => [];
118
+
119
+ beforeEach(() => {
120
+ jest.resetAllMocks();
121
+ jest.spyOn(console, 'log').mockImplementation(() => undefined);
122
+ jest.spyOn(console, 'error').mockImplementation(() => undefined);
123
+
124
+ handoverSessionRepository = {
125
+ listHandoverSessions: jest.fn().mockReturnValue([]),
126
+ };
127
+ snapshotRepository = {
128
+ listSnapshots: jest.fn().mockReturnValue([]),
129
+ };
130
+ tmuxSessionRepository = {
131
+ sendKeys: jest.fn().mockResolvedValue(undefined),
132
+ killSession: jest.fn().mockResolvedValue(undefined),
133
+ listLiveSessionNames: jest.fn().mockResolvedValue([]),
134
+ launchBareNameLeaderSession: jest.fn().mockResolvedValue(undefined),
135
+ };
136
+ processSignalRepository = {
137
+ isProcessAlive: jest.fn().mockReturnValue(true),
138
+ terminateProcess: jest.fn(),
139
+ killProcess: jest.fn(),
140
+ };
141
+
142
+ useCase = new TokenExhaustionHandoverUseCase(
143
+ handoverSessionRepository,
144
+ snapshotRepository,
145
+ tmuxSessionRepository,
146
+ processSignalRepository,
147
+ );
148
+ });
149
+
150
+ it('does nothing when there are no sessions', async () => {
151
+ const result = await useCase.run(defaultInput());
152
+
153
+ expect(result.newlyHandoverSentSessionNames).toEqual([]);
154
+ expect(result.killedSessionNames).toEqual([]);
155
+ expect(tmuxSessionRepository.sendKeys).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it('logs a cycle summary every run so the dry-run step is observable', async () => {
159
+ const logSpy = jest.spyOn(console, 'log');
160
+
161
+ await useCase.run(defaultInput({ enabled: false }));
162
+
163
+ expect(logSpy).toHaveBeenCalledWith(
164
+ 'Token exhaustion handover: cycle summary evaluated=0 enabled=false signaled=0 killed=0 terminatedPids=0 relaunched=0 leftAlive=0',
165
+ );
166
+ });
167
+
168
+ it('skips a session whose token has no snapshot', async () => {
169
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
170
+ issueUrlLeaderSession(),
171
+ ]);
172
+ snapshotRepository.listSnapshots.mockReturnValue([]);
173
+
174
+ const result = await useCase.run(defaultInput());
175
+
176
+ expect(result.newlyHandoverSentSessionNames).toEqual([]);
177
+ expect(tmuxSessionRepository.sendKeys).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it('skips a non-exhausted session and clears any stale state entry', async () => {
181
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
182
+ issueUrlLeaderSession(),
183
+ ]);
184
+ snapshotRepository.listSnapshots.mockReturnValue([
185
+ snapshot(TOKEN_EXHAUSTED),
186
+ snapshot(TOKEN_FRESH),
187
+ ]);
188
+
189
+ const result = await useCase.run(
190
+ defaultInput({
191
+ state: {
192
+ entries: {
193
+ [ISSUE_URL_SESSION]: {
194
+ signaledAtEpoch: nowEpochSeconds,
195
+ pid: LEADER_PID,
196
+ },
197
+ },
198
+ },
199
+ }),
200
+ );
201
+
202
+ expect(result.newlyHandoverSentSessionNames).toEqual([]);
203
+ expect(result.state.entries[ISSUE_URL_SESSION]).toBeUndefined();
204
+ });
205
+
206
+ it('skips a hard-stale snapshot even when the last reading was exhausted', async () => {
207
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
208
+ issueUrlLeaderSession(),
209
+ ]);
210
+ snapshotRepository.listSnapshots.mockReturnValue([
211
+ snapshot(TOKEN_EXHAUSTED, {
212
+ fiveHourUtilization: 0.99,
213
+ lastUpdatedEpoch:
214
+ nowEpochSeconds -
215
+ TOKEN_EXHAUSTION_SNAPSHOT_HARD_STALE_THRESHOLD_SECONDS -
216
+ 1,
217
+ }),
218
+ snapshot(TOKEN_FRESH),
219
+ ]);
220
+
221
+ const result = await useCase.run(defaultInput());
222
+
223
+ expect(result.newlyHandoverSentSessionNames).toEqual([]);
224
+ expect(tmuxSessionRepository.sendKeys).not.toHaveBeenCalled();
225
+ });
226
+
227
+ it('acts on a slightly-stale snapshot when the last reading was near exhaustion', async () => {
228
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
229
+ issueUrlLeaderSession(),
230
+ ]);
231
+ snapshotRepository.listSnapshots.mockReturnValue([
232
+ snapshot(TOKEN_EXHAUSTED, {
233
+ fiveHourUtilization: 0.99,
234
+ lastUpdatedEpoch:
235
+ nowEpochSeconds -
236
+ TOKEN_EXHAUSTION_SNAPSHOT_STALE_THRESHOLD_SECONDS -
237
+ 10,
238
+ }),
239
+ snapshot(TOKEN_FRESH),
240
+ ]);
241
+
242
+ const result = await useCase.run(defaultInput());
243
+
244
+ expect(result.newlyHandoverSentSessionNames).toEqual([ISSUE_URL_SESSION]);
245
+ expect(tmuxSessionRepository.sendKeys).toHaveBeenCalledWith(
246
+ ISSUE_URL_SESSION,
247
+ DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE,
248
+ );
249
+ });
250
+
251
+ it('skips a slightly-stale snapshot when the last reading was healthy', async () => {
252
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
253
+ issueUrlLeaderSession(),
254
+ ]);
255
+ snapshotRepository.listSnapshots.mockReturnValue([
256
+ snapshot(TOKEN_EXHAUSTED, {
257
+ fiveHourUtilization: 0,
258
+ lastUpdatedEpoch:
259
+ nowEpochSeconds -
260
+ TOKEN_EXHAUSTION_SNAPSHOT_STALE_THRESHOLD_SECONDS -
261
+ 10,
262
+ }),
263
+ snapshot(TOKEN_FRESH),
264
+ ]);
265
+
266
+ const result = await useCase.run(defaultInput());
267
+
268
+ expect(result.newlyHandoverSentSessionNames).toEqual([]);
269
+ });
270
+
271
+ it('leaves an exhausted session alive when no fresher token is available', async () => {
272
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
273
+ issueUrlLeaderSession(),
274
+ ]);
275
+ snapshotRepository.listSnapshots.mockReturnValue([
276
+ snapshot(TOKEN_EXHAUSTED, { fiveHourUtilization: 0.99 }),
277
+ snapshot(TOKEN_FRESH, { fiveHourUtilization: 0.99 }),
278
+ ]);
279
+
280
+ const result = await useCase.run(defaultInput());
281
+
282
+ expect(result.leftAliveSessionNames).toEqual([ISSUE_URL_SESSION]);
283
+ expect(result.newlyHandoverSentSessionNames).toEqual([]);
284
+ expect(tmuxSessionRepository.sendKeys).not.toHaveBeenCalled();
285
+ });
286
+
287
+ it('sends the issue-URL leader checkpoint message on first detection', async () => {
288
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
289
+ issueUrlLeaderSession(),
290
+ ]);
291
+ snapshotRepository.listSnapshots.mockReturnValue([
292
+ snapshot(TOKEN_EXHAUSTED, { fiveHourUtilization: 0.95 }),
293
+ snapshot(TOKEN_FRESH),
294
+ ]);
295
+
296
+ const result = await useCase.run(defaultInput());
297
+
298
+ expect(tmuxSessionRepository.sendKeys).toHaveBeenCalledWith(
299
+ ISSUE_URL_SESSION,
300
+ DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE,
301
+ );
302
+ expect(result.state.entries[ISSUE_URL_SESSION]).toEqual({
303
+ signaledAtEpoch: nowEpochSeconds,
304
+ pid: LEADER_PID,
305
+ });
306
+ });
307
+
308
+ it('sends the bare-name leader checkpoint message on first detection', async () => {
309
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
310
+ bareNameLeaderSession(),
311
+ ]);
312
+ snapshotRepository.listSnapshots.mockReturnValue([
313
+ snapshot(TOKEN_EXHAUSTED, { fiveHourUtilization: 0.95 }),
314
+ snapshot(TOKEN_FRESH),
315
+ ]);
316
+
317
+ await useCase.run(defaultInput());
318
+
319
+ expect(tmuxSessionRepository.sendKeys).toHaveBeenCalledWith(
320
+ BARE_NAME,
321
+ DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE_BARE_NAME_LEADER,
322
+ );
323
+ });
324
+
325
+ it('sends SIGTERM to an impl subagent on first detection', async () => {
326
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
327
+ implSubagentSession(),
328
+ ]);
329
+ snapshotRepository.listSnapshots.mockReturnValue([
330
+ snapshot(TOKEN_EXHAUSTED, { fiveHourUtilization: 0.95 }),
331
+ snapshot(TOKEN_FRESH),
332
+ ]);
333
+
334
+ const result = await useCase.run(defaultInput());
335
+
336
+ expect(processSignalRepository.terminateProcess).toHaveBeenCalledWith(
337
+ IMPL_PID,
338
+ );
339
+ expect(tmuxSessionRepository.sendKeys).not.toHaveBeenCalled();
340
+ expect(result.state.entries[`pid:${IMPL_PID}`]).toEqual({
341
+ signaledAtEpoch: nowEpochSeconds,
342
+ pid: IMPL_PID,
343
+ });
344
+ });
345
+
346
+ it('does not treat an impl subagent as exhausted on seven-day utilization alone', async () => {
347
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
348
+ implSubagentSession(),
349
+ ]);
350
+ snapshotRepository.listSnapshots.mockReturnValue([
351
+ snapshot(TOKEN_EXHAUSTED, {
352
+ fiveHourUtilization: 0,
353
+ sevenDayUtilization: 0.99,
354
+ }),
355
+ snapshot(TOKEN_FRESH),
356
+ ]);
357
+
358
+ const result = await useCase.run(defaultInput());
359
+
360
+ expect(result.newlyHandoverSentSessionNames).toEqual([]);
361
+ expect(processSignalRepository.terminateProcess).not.toHaveBeenCalled();
362
+ });
363
+
364
+ it('waits while the grace period has not elapsed', async () => {
365
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
366
+ issueUrlLeaderSession(),
367
+ ]);
368
+ snapshotRepository.listSnapshots.mockReturnValue([
369
+ snapshot(TOKEN_EXHAUSTED, { fiveHourUtilization: 0.95 }),
370
+ snapshot(TOKEN_FRESH),
371
+ ]);
372
+
373
+ const result = await useCase.run(
374
+ defaultInput({
375
+ state: {
376
+ entries: {
377
+ [ISSUE_URL_SESSION]: {
378
+ signaledAtEpoch: nowEpochSeconds - 10,
379
+ pid: LEADER_PID,
380
+ },
381
+ },
382
+ },
383
+ }),
384
+ );
385
+
386
+ expect(result.killedSessionNames).toEqual([]);
387
+ expect(tmuxSessionRepository.killSession).not.toHaveBeenCalled();
388
+ expect(result.state.entries[ISSUE_URL_SESSION]).toEqual({
389
+ signaledAtEpoch: nowEpochSeconds - 10,
390
+ pid: LEADER_PID,
391
+ });
392
+ });
393
+
394
+ it('kills an issue-URL leader after the grace period without relaunching it', async () => {
395
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
396
+ issueUrlLeaderSession(),
397
+ ]);
398
+ snapshotRepository.listSnapshots.mockReturnValue([
399
+ snapshot(TOKEN_EXHAUSTED, { fiveHourUtilization: 0.95 }),
400
+ snapshot(TOKEN_FRESH),
401
+ ]);
402
+ tmuxSessionRepository.listLiveSessionNames.mockResolvedValue([
403
+ ISSUE_URL_SESSION,
404
+ ]);
405
+
406
+ const result = await useCase.run(
407
+ defaultInput({
408
+ state: {
409
+ entries: {
410
+ [ISSUE_URL_SESSION]: {
411
+ signaledAtEpoch:
412
+ nowEpochSeconds -
413
+ DEFAULT_TOKEN_EXHAUSTION_GRACE_PERIOD_SECONDS -
414
+ 1,
415
+ pid: LEADER_PID,
416
+ },
417
+ },
418
+ },
419
+ }),
420
+ );
421
+
422
+ expect(tmuxSessionRepository.killSession).toHaveBeenCalledWith(
423
+ ISSUE_URL_SESSION,
424
+ );
425
+ expect(
426
+ tmuxSessionRepository.launchBareNameLeaderSession,
427
+ ).not.toHaveBeenCalled();
428
+ expect(result.killedSessionNames).toEqual([ISSUE_URL_SESSION]);
429
+ expect(result.state.entries[ISSUE_URL_SESSION]).toBeUndefined();
430
+ });
431
+
432
+ it('kills and relaunches a bare-name leader after the grace period', async () => {
433
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
434
+ bareNameLeaderSession(),
435
+ ]);
436
+ snapshotRepository.listSnapshots.mockReturnValue([
437
+ snapshot(TOKEN_EXHAUSTED, { fiveHourUtilization: 0.95 }),
438
+ snapshot(TOKEN_FRESH),
439
+ ]);
440
+ tmuxSessionRepository.listLiveSessionNames.mockResolvedValue([BARE_NAME]);
441
+
442
+ const result = await useCase.run(
443
+ defaultInput({
444
+ state: {
445
+ entries: {
446
+ [BARE_NAME]: {
447
+ signaledAtEpoch:
448
+ nowEpochSeconds -
449
+ DEFAULT_TOKEN_EXHAUSTION_GRACE_PERIOD_SECONDS -
450
+ 1,
451
+ pid: LEADER_PID,
452
+ },
453
+ },
454
+ },
455
+ }),
456
+ );
457
+
458
+ expect(tmuxSessionRepository.killSession).toHaveBeenCalledWith(BARE_NAME);
459
+ expect(
460
+ tmuxSessionRepository.launchBareNameLeaderSession,
461
+ ).toHaveBeenCalledWith(BARE_NAME);
462
+ expect(result.relaunchedLeaderNames).toEqual([BARE_NAME]);
463
+ expect(result.killedSessionNames).toEqual([BARE_NAME]);
464
+ });
465
+
466
+ it('SIGKILLs an impl subagent that is still alive after the grace period', async () => {
467
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
468
+ implSubagentSession(),
469
+ ]);
470
+ snapshotRepository.listSnapshots.mockReturnValue([
471
+ snapshot(TOKEN_EXHAUSTED, { fiveHourUtilization: 0.95 }),
472
+ snapshot(TOKEN_FRESH),
473
+ ]);
474
+ processSignalRepository.isProcessAlive.mockReturnValue(true);
475
+
476
+ const result = await useCase.run(
477
+ defaultInput({
478
+ state: {
479
+ entries: {
480
+ [`pid:${IMPL_PID}`]: {
481
+ signaledAtEpoch:
482
+ nowEpochSeconds -
483
+ DEFAULT_TOKEN_EXHAUSTION_GRACE_PERIOD_SECONDS -
484
+ 1,
485
+ pid: IMPL_PID,
486
+ },
487
+ },
488
+ },
489
+ }),
490
+ );
491
+
492
+ expect(processSignalRepository.killProcess).toHaveBeenCalledWith(IMPL_PID);
493
+ expect(result.terminatedPids).toEqual([IMPL_PID]);
494
+ });
495
+
496
+ it('clears state and relaunches a bare-name leader that already exited after handover', async () => {
497
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
498
+ bareNameLeaderSession(),
499
+ ]);
500
+ snapshotRepository.listSnapshots.mockReturnValue([
501
+ snapshot(TOKEN_EXHAUSTED, { fiveHourUtilization: 0.95 }),
502
+ snapshot(TOKEN_FRESH),
503
+ ]);
504
+ tmuxSessionRepository.listLiveSessionNames.mockResolvedValue([]);
505
+
506
+ const result = await useCase.run(
507
+ defaultInput({
508
+ state: {
509
+ entries: {
510
+ [BARE_NAME]: {
511
+ signaledAtEpoch:
512
+ nowEpochSeconds -
513
+ DEFAULT_TOKEN_EXHAUSTION_GRACE_PERIOD_SECONDS -
514
+ 1,
515
+ pid: LEADER_PID,
516
+ },
517
+ },
518
+ },
519
+ }),
520
+ );
521
+
522
+ expect(tmuxSessionRepository.killSession).not.toHaveBeenCalled();
523
+ expect(
524
+ tmuxSessionRepository.launchBareNameLeaderSession,
525
+ ).toHaveBeenCalledWith(BARE_NAME);
526
+ expect(result.state.entries[BARE_NAME]).toBeUndefined();
527
+ });
528
+
529
+ it('treats a rejected weekly hard cap with a future reset as exhausted', async () => {
530
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
531
+ issueUrlLeaderSession(),
532
+ ]);
533
+ const weeklyCap: TokenModelWeeklyLimit[] = [
534
+ { rejected: true, resetsAt: nowEpochSeconds + 3600 },
535
+ ];
536
+ snapshotRepository.listSnapshots.mockReturnValue([
537
+ snapshot(TOKEN_EXHAUSTED, { modelWeeklyLimits: weeklyCap }),
538
+ snapshot(TOKEN_FRESH),
539
+ ]);
540
+
541
+ const result = await useCase.run(defaultInput());
542
+
543
+ expect(result.newlyHandoverSentSessionNames).toEqual([ISSUE_URL_SESSION]);
544
+ });
545
+
546
+ it('ignores a rejected weekly hard cap whose reset is already in the past', async () => {
547
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
548
+ issueUrlLeaderSession(),
549
+ ]);
550
+ const weeklyCap: TokenModelWeeklyLimit[] = [
551
+ { rejected: true, resetsAt: nowEpochSeconds - 3600 },
552
+ ];
553
+ snapshotRepository.listSnapshots.mockReturnValue([
554
+ snapshot(TOKEN_EXHAUSTED, { modelWeeklyLimits: weeklyCap }),
555
+ snapshot(TOKEN_FRESH),
556
+ ]);
557
+
558
+ const result = await useCase.run(defaultInput());
559
+
560
+ expect(result.newlyHandoverSentSessionNames).toEqual([]);
561
+ });
562
+
563
+ it('performs no side effects in dry-run mode but still records grace state', async () => {
564
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
565
+ issueUrlLeaderSession(),
566
+ ]);
567
+ snapshotRepository.listSnapshots.mockReturnValue([
568
+ snapshot(TOKEN_EXHAUSTED, { fiveHourUtilization: 0.95 }),
569
+ snapshot(TOKEN_FRESH),
570
+ ]);
571
+
572
+ const result = await useCase.run(defaultInput({ enabled: false }));
573
+
574
+ expect(tmuxSessionRepository.sendKeys).not.toHaveBeenCalled();
575
+ expect(result.newlyHandoverSentSessionNames).toEqual([ISSUE_URL_SESSION]);
576
+ expect(result.state.entries[ISSUE_URL_SESSION]).toEqual({
577
+ signaledAtEpoch: nowEpochSeconds,
578
+ pid: LEADER_PID,
579
+ });
580
+ });
581
+
582
+ it('does not kill or claim a kill in dry-run mode after the grace period elapses', async () => {
583
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
584
+ issueUrlLeaderSession(),
585
+ ]);
586
+ snapshotRepository.listSnapshots.mockReturnValue([
587
+ snapshot(TOKEN_EXHAUSTED, { fiveHourUtilization: 0.95 }),
588
+ snapshot(TOKEN_FRESH),
589
+ ]);
590
+ tmuxSessionRepository.listLiveSessionNames.mockResolvedValue([
591
+ ISSUE_URL_SESSION,
592
+ ]);
593
+
594
+ const result = await useCase.run(
595
+ defaultInput({
596
+ enabled: false,
597
+ state: {
598
+ entries: {
599
+ [ISSUE_URL_SESSION]: {
600
+ signaledAtEpoch:
601
+ nowEpochSeconds -
602
+ DEFAULT_TOKEN_EXHAUSTION_GRACE_PERIOD_SECONDS -
603
+ 1,
604
+ pid: LEADER_PID,
605
+ },
606
+ },
607
+ },
608
+ }),
609
+ );
610
+
611
+ expect(tmuxSessionRepository.killSession).not.toHaveBeenCalled();
612
+ expect(processSignalRepository.killProcess).not.toHaveBeenCalled();
613
+ expect(result.killedSessionNames).toEqual([]);
614
+ expect(result.terminatedPids).toEqual([]);
615
+ });
616
+
617
+ it('preserves other sessions grace state when one session throws', async () => {
618
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
619
+ issueUrlLeaderSession(),
620
+ bareNameLeaderSession(),
621
+ ]);
622
+ snapshotRepository.listSnapshots.mockReturnValue([
623
+ snapshot(TOKEN_EXHAUSTED, { fiveHourUtilization: 0.95 }),
624
+ snapshot(TOKEN_FRESH),
625
+ ]);
626
+ tmuxSessionRepository.sendKeys.mockImplementation(
627
+ async (sessionName: string) => {
628
+ if (sessionName === ISSUE_URL_SESSION) {
629
+ throw new Error('send-keys failed');
630
+ }
631
+ },
632
+ );
633
+
634
+ const result = await useCase.run(defaultInput());
635
+
636
+ expect(result.state.entries[ISSUE_URL_SESSION]).toBeUndefined();
637
+ expect(result.state.entries[BARE_NAME]).toEqual({
638
+ signaledAtEpoch: nowEpochSeconds,
639
+ pid: LEADER_PID,
640
+ });
641
+ expect(result.newlyHandoverSentSessionNames).toEqual([BARE_NAME]);
642
+ });
643
+
644
+ it('uses a custom issue-URL leader message when provided', async () => {
645
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
646
+ issueUrlLeaderSession(),
647
+ ]);
648
+ snapshotRepository.listSnapshots.mockReturnValue([
649
+ snapshot(TOKEN_EXHAUSTED, { fiveHourUtilization: 0.95 }),
650
+ snapshot(TOKEN_FRESH),
651
+ ]);
652
+
653
+ await useCase.run(
654
+ defaultInput({ issueUrlLeaderMessage: 'custom checkpoint now' }),
655
+ );
656
+
657
+ expect(tmuxSessionRepository.sendKeys).toHaveBeenCalledWith(
658
+ ISSUE_URL_SESSION,
659
+ 'custom checkpoint now',
660
+ );
661
+ });
662
+
663
+ it('detects exhaustion via a rejected window status', async () => {
664
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
665
+ issueUrlLeaderSession(),
666
+ ]);
667
+ snapshotRepository.listSnapshots.mockReturnValue([
668
+ snapshot(TOKEN_EXHAUSTED, {
669
+ rejected: true,
670
+ modelWeeklyLimits: exhaustedFiveHour(),
671
+ }),
672
+ snapshot(TOKEN_FRESH),
673
+ ]);
674
+
675
+ const result = await useCase.run(defaultInput());
676
+
677
+ expect(result.newlyHandoverSentSessionNames).toEqual([ISSUE_URL_SESSION]);
678
+ });
679
+
680
+ it('treats the five-hour window as free after its reset epoch has passed', async () => {
681
+ handoverSessionRepository.listHandoverSessions.mockReturnValue([
682
+ issueUrlLeaderSession(),
683
+ ]);
684
+ snapshotRepository.listSnapshots.mockReturnValue([
685
+ snapshot(TOKEN_EXHAUSTED, {
686
+ fiveHourUtilization: 1,
687
+ fiveHourReset: nowEpochSeconds - 1,
688
+ }),
689
+ snapshot(TOKEN_FRESH),
690
+ ]);
691
+
692
+ const result = await useCase.run(defaultInput());
693
+
694
+ expect(result.newlyHandoverSentSessionNames).toEqual([]);
695
+ });
696
+ });