github-issue-tower-defence-management 1.139.1 → 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 (81) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +6 -0
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +43 -2
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js +31 -0
  6. package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js.map +1 -0
  7. package/bin/adapter/repositories/FileHandoverStateRepository.js +99 -0
  8. package/bin/adapter/repositories/FileHandoverStateRepository.js.map +1 -0
  9. package/bin/adapter/repositories/NodeProcessSignalRepository.js +44 -0
  10. package/bin/adapter/repositories/NodeProcessSignalRepository.js.map +1 -0
  11. package/bin/adapter/repositories/NodeTmuxSessionRepository.js +67 -1
  12. package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
  13. package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js +206 -0
  14. package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js.map +1 -0
  15. package/bin/adapter/repositories/RateLimitSnapshotRepository.js +44 -0
  16. package/bin/adapter/repositories/RateLimitSnapshotRepository.js.map +1 -0
  17. package/bin/domain/entities/ClaudeHandoverSession.js +3 -0
  18. package/bin/domain/entities/ClaudeHandoverSession.js.map +1 -0
  19. package/bin/domain/entities/TokenExhaustionHandoverState.js +3 -0
  20. package/bin/domain/entities/TokenExhaustionHandoverState.js.map +1 -0
  21. package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js +255 -0
  22. package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js.map +1 -0
  23. package/bin/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.js +3 -0
  24. package/bin/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.js.map +1 -0
  25. package/bin/domain/usecases/adapter-interfaces/ProcessSignalRepository.js +3 -0
  26. package/bin/domain/usecases/adapter-interfaces/ProcessSignalRepository.js.map +1 -0
  27. package/bin/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.js +3 -0
  28. package/bin/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.js.map +1 -0
  29. package/bin/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.js +3 -0
  30. package/bin/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +32 -0
  33. package/src/adapter/entry-points/handlers/tokenExhaustionHandover.ts +79 -0
  34. package/src/adapter/repositories/FileHandoverStateRepository.test.ts +114 -0
  35. package/src/adapter/repositories/FileHandoverStateRepository.ts +76 -0
  36. package/src/adapter/repositories/NodeProcessSignalRepository.test.ts +82 -0
  37. package/src/adapter/repositories/NodeProcessSignalRepository.ts +47 -0
  38. package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +132 -0
  39. package/src/adapter/repositories/NodeTmuxSessionRepository.ts +100 -0
  40. package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.test.ts +194 -0
  41. package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.ts +208 -0
  42. package/src/adapter/repositories/RateLimitSnapshotRepository.ts +50 -0
  43. package/src/domain/entities/ClaudeHandoverSession.ts +11 -0
  44. package/src/domain/entities/TokenExhaustionHandoverState.ts +8 -0
  45. package/src/domain/usecases/TokenExhaustionHandoverUseCase.test.ts +696 -0
  46. package/src/domain/usecases/TokenExhaustionHandoverUseCase.ts +413 -0
  47. package/src/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.ts +5 -0
  48. package/src/domain/usecases/adapter-interfaces/ProcessSignalRepository.ts +5 -0
  49. package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +2 -0
  50. package/src/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.ts +6 -0
  51. package/src/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.ts +22 -0
  52. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +2 -0
  53. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  54. package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts +14 -0
  55. package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts.map +1 -0
  56. package/types/adapter/repositories/FileHandoverStateRepository.d.ts +11 -0
  57. package/types/adapter/repositories/FileHandoverStateRepository.d.ts.map +1 -0
  58. package/types/adapter/repositories/NodeProcessSignalRepository.d.ts +8 -0
  59. package/types/adapter/repositories/NodeProcessSignalRepository.d.ts.map +1 -0
  60. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +7 -1
  61. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
  62. package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts +13 -0
  63. package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts.map +1 -0
  64. package/types/adapter/repositories/RateLimitSnapshotRepository.d.ts +9 -0
  65. package/types/adapter/repositories/RateLimitSnapshotRepository.d.ts.map +1 -0
  66. package/types/domain/entities/ClaudeHandoverSession.d.ts +10 -0
  67. package/types/domain/entities/ClaudeHandoverSession.d.ts.map +1 -0
  68. package/types/domain/entities/TokenExhaustionHandoverState.d.ts +8 -0
  69. package/types/domain/entities/TokenExhaustionHandoverState.d.ts.map +1 -0
  70. package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts +50 -0
  71. package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts.map +1 -0
  72. package/types/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.d.ts +5 -0
  73. package/types/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.d.ts.map +1 -0
  74. package/types/domain/usecases/adapter-interfaces/ProcessSignalRepository.d.ts +6 -0
  75. package/types/domain/usecases/adapter-interfaces/ProcessSignalRepository.d.ts.map +1 -0
  76. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +2 -0
  77. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
  78. package/types/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.d.ts +6 -0
  79. package/types/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.d.ts.map +1 -0
  80. package/types/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.d.ts +21 -0
  81. 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
+ });