github-issue-tower-defence-management 1.193.0 → 1.194.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 (74) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +4 -0
  3. package/bin/adapter/entry-points/cli/index.js +19 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/ui-dist/assets/{index-RaFqjB7u.js → index-CYwLv9pf.js} +2 -2
  6. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  7. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +1 -0
  8. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
  9. package/bin/adapter/repositories/NodeLocalCommandRunner.js +5 -0
  10. package/bin/adapter/repositories/NodeLocalCommandRunner.js.map +1 -1
  11. package/bin/adapter/repositories/NodeTmuxSessionRepository.js +29 -0
  12. package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
  13. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +4 -2
  14. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
  15. package/bin/domain/usecases/StartPreparationUseCase.js +12 -2
  16. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  17. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +1 -3
  18. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
  19. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +12 -4
  20. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/adapter/entry-points/cli/index.test.ts +102 -0
  23. package/src/adapter/entry-points/cli/index.ts +43 -0
  24. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +45 -0
  25. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +7 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.stories.tsx +31 -0
  27. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.test.tsx +96 -0
  28. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.tsx +20 -2
  29. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +159 -0
  30. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +17 -1
  31. package/src/adapter/entry-points/console/ui-dist/assets/{index-RaFqjB7u.js → index-CYwLv9pf.js} +2 -2
  32. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  33. package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.test.ts +1 -0
  34. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +1 -0
  35. package/src/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.test.ts +4 -1
  36. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +2 -0
  37. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +1 -0
  38. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +1 -0
  39. package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.test.ts +4 -0
  40. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +1 -0
  41. package/src/adapter/repositories/NodeLocalCommandRunner.test.ts +16 -0
  42. package/src/adapter/repositories/NodeLocalCommandRunner.ts +5 -1
  43. package/src/adapter/repositories/NodeSubAgentProcessLister.test.ts +1 -0
  44. package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +132 -0
  45. package/src/adapter/repositories/NodeTmuxSessionRepository.ts +37 -0
  46. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +38 -0
  47. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +6 -2
  48. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +2 -0
  49. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +1 -0
  50. package/src/domain/usecases/StartPreparationUseCase.test.ts +110 -2
  51. package/src/domain/usecases/StartPreparationUseCase.ts +34 -17
  52. package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +1 -0
  53. package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +4 -0
  54. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +5 -4
  55. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +9 -11
  56. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +63 -0
  57. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +13 -4
  58. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +1 -0
  59. package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +1 -0
  60. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  61. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
  62. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts +1 -0
  63. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts.map +1 -1
  64. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +1 -0
  65. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
  66. package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
  67. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  68. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +1 -0
  69. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -1
  70. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +1 -0
  71. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
  72. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
  73. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +1 -0
  74. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -1
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-RaFqjB7u.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-CYwLv9pf.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-CLHvrafO.css">
9
9
  </head>
10
10
  <body>
@@ -40,6 +40,7 @@ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
40
40
 
41
41
  const createMockRunner = (): Mocked<LocalCommandRunner> => ({
42
42
  runCommand: jest.fn(),
43
+ spawnInteractive: jest.fn(),
43
44
  });
44
45
 
45
46
  const createIssueStateRepository = (
@@ -38,6 +38,7 @@ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
38
38
  stderr: '',
39
39
  exitCode: 0,
40
40
  }),
41
+ spawnInteractive: jest.fn(),
41
42
  });
42
43
 
43
44
  describe('notifySilentTmuxSessions', () => {
@@ -34,7 +34,10 @@ describe('resetDegeneratedTmuxSessions', () => {
34
34
  it('wires the real adapters and runs end to end without acting when no sessions exist', async () => {
35
35
  const runCommand: jest.MockedFunction<LocalCommandRunner['runCommand']> =
36
36
  jest.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
37
- const commandRunner: LocalCommandRunner = { runCommand };
37
+ const commandRunner: LocalCommandRunner = {
38
+ runCommand,
39
+ spawnInteractive: jest.fn(),
40
+ };
38
41
 
39
42
  await resetDegeneratedTmuxSessions({
40
43
  enabled: false,
@@ -413,6 +413,7 @@ describe('writeSituationFile', () => {
413
413
  );
414
414
  const mockRunner: LocalCommandRunner = {
415
415
  runCommand: mockRunCommand,
416
+ spawnInteractive: jest.fn(),
416
417
  };
417
418
  const issues = [
418
419
  createIssue({
@@ -444,6 +445,7 @@ describe('writeSituationFile', () => {
444
445
  const mockRunCommand = jest.fn();
445
446
  const mockRunner: LocalCommandRunner = {
446
447
  runCommand: mockRunCommand,
448
+ spawnInteractive: jest.fn(),
447
449
  };
448
450
 
449
451
  await writeSituationFile({
@@ -60,6 +60,7 @@ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
60
60
 
61
61
  const createMockRunner = (): Mocked<LocalCommandRunner> => ({
62
62
  runCommand: jest.fn(),
63
+ spawnInteractive: jest.fn(),
63
64
  });
64
65
 
65
66
  const createMockIssueRepository = (
@@ -140,6 +140,7 @@ export const toTokenRateLimitSnapshot = (
140
140
  sevenDayOpusRejected:
141
141
  snapshot.modelWeeklyLimits[SEVEN_DAY_OPUS_LIMIT_TYPE]?.rejected ?? false,
142
142
  hasWindowData,
143
+ lastUpdatedEpoch: snapshot.lastUpdatedEpoch,
143
144
  };
144
145
  };
145
146
 
@@ -84,6 +84,7 @@ describe('LocalCommandIssueAttachmentRepository', () => {
84
84
  exitCode: 0,
85
85
  };
86
86
  },
87
+ spawnInteractive: jest.fn(),
87
88
  };
88
89
  const repository = new LocalCommandIssueAttachmentRepository(
89
90
  runner,
@@ -117,6 +118,7 @@ describe('LocalCommandIssueAttachmentRepository', () => {
117
118
  exitCode: 0,
118
119
  };
119
120
  },
121
+ spawnInteractive: jest.fn(),
120
122
  };
121
123
  const repository = new LocalCommandIssueAttachmentRepository(
122
124
  runner,
@@ -148,6 +150,7 @@ describe('LocalCommandIssueAttachmentRepository', () => {
148
150
  stderr: 'Error: No GitHub web session is available\n',
149
151
  exitCode: 1,
150
152
  }),
153
+ spawnInteractive: jest.fn(),
151
154
  };
152
155
  const repository = new LocalCommandIssueAttachmentRepository(
153
156
  runner,
@@ -171,6 +174,7 @@ describe('LocalCommandIssueAttachmentRepository', () => {
171
174
  const root = await mkdtemp(join(tmpdir(), 'attachment-test-root-'));
172
175
  const runner: LocalCommandRunner = {
173
176
  runCommand: async () => ({ stdout: '\n', stderr: '', exitCode: 0 }),
177
+ spawnInteractive: jest.fn(),
174
178
  };
175
179
  const repository = new LocalCommandIssueAttachmentRepository(
176
180
  runner,
@@ -22,6 +22,7 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
22
22
  exitCode: handler.exitCode ?? 0,
23
23
  };
24
24
  }),
25
+ spawnInteractive: jest.fn(),
25
26
  });
26
27
 
27
28
  const makeEnvironReader = (
@@ -1,7 +1,11 @@
1
1
  const mockExecFileAsync = jest.fn();
2
+ const mockSpawnSync = jest.fn();
2
3
 
3
4
  jest.mock('child_process', () => ({
4
5
  execFile: jest.fn(),
6
+ spawnSync: jest.fn((...args: unknown[]) => {
7
+ mockSpawnSync(...args);
8
+ }),
5
9
  }));
6
10
 
7
11
  jest.mock('util', () => ({
@@ -99,4 +103,16 @@ describe('NodeLocalCommandRunner', () => {
99
103
  );
100
104
  });
101
105
  });
106
+
107
+ describe('spawnInteractive', () => {
108
+ it('calls spawnSync with stdio inherit', () => {
109
+ runner.spawnInteractive('tmux', ['attach-session', '-t', '=wq7x3mk']);
110
+
111
+ expect(mockSpawnSync).toHaveBeenCalledWith(
112
+ 'tmux',
113
+ ['attach-session', '-t', '=wq7x3mk'],
114
+ { stdio: 'inherit' },
115
+ );
116
+ });
117
+ });
102
118
  });
@@ -2,7 +2,7 @@ import {
2
2
  LocalCommandRunner,
3
3
  LocalCommandRunnerOptions,
4
4
  } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
5
- import { execFile } from 'child_process';
5
+ import { execFile, spawnSync } from 'child_process';
6
6
  import { promisify } from 'util';
7
7
 
8
8
  const execFileAsync = promisify(execFile);
@@ -59,4 +59,8 @@ export class NodeLocalCommandRunner implements LocalCommandRunner {
59
59
  throw error;
60
60
  }
61
61
  }
62
+
63
+ spawnInteractive = (program: string, args: string[]): void => {
64
+ spawnSync(program, args, { stdio: 'inherit' });
65
+ };
62
66
  }
@@ -8,6 +8,7 @@ const createRunner = (
8
8
  exitCode = 0,
9
9
  ): Mocked<LocalCommandRunner> => ({
10
10
  runCommand: jest.fn().mockResolvedValue({ stdout, stderr: '', exitCode }),
11
+ spawnInteractive: jest.fn(),
11
12
  });
12
13
 
13
14
  describe('NodeSubAgentProcessLister', () => {
@@ -8,6 +8,7 @@ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
8
8
 
9
9
  const createMockRunner = (): Mocked<LocalCommandRunner> => ({
10
10
  runCommand: jest.fn(),
11
+ spawnInteractive: jest.fn(),
11
12
  });
12
13
 
13
14
  describe('NodeTmuxSessionRepository', () => {
@@ -411,4 +412,135 @@ describe('NodeTmuxSessionRepository', () => {
411
412
  ).rejects.toThrow('Failed to relaunch bare-name leader session "app"');
412
413
  });
413
414
  });
415
+
416
+ describe('attachOrCreateInteractiveSession', () => {
417
+ const issueUrl = 'https://github.com/owner/repo/issues/9';
418
+
419
+ it('attaches to the registered session when the registry returns a live session name', async () => {
420
+ const runner = createMockRunner();
421
+ runner.runCommand
422
+ .mockResolvedValueOnce({ stdout: 'wq7x3mk\n', stderr: '', exitCode: 0 })
423
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
424
+ const repository = new NodeTmuxSessionRepository(runner);
425
+
426
+ await repository.attachOrCreateInteractiveSession(
427
+ issueUrl,
428
+ '/path/to/cl-scope-lib.sh',
429
+ );
430
+
431
+ expect(runner.runCommand.mock.calls[0]).toEqual([
432
+ 'bash',
433
+ ['/path/to/cl-scope-lib.sh', 'registry-get-session', issueUrl],
434
+ ]);
435
+ expect(runner.runCommand.mock.calls[1]).toEqual([
436
+ 'tmux',
437
+ ['has-session', '-t', '=wq7x3mk'],
438
+ ]);
439
+ expect(runner.spawnInteractive.mock.calls).toEqual([
440
+ ['tmux', ['attach-session', '-t', '=wq7x3mk']],
441
+ ]);
442
+ });
443
+
444
+ it('creates a new session when no session is registered', async () => {
445
+ const runner = createMockRunner();
446
+ runner.runCommand.mockResolvedValueOnce({
447
+ stdout: '',
448
+ stderr: '',
449
+ exitCode: 0,
450
+ });
451
+ const repository = new NodeTmuxSessionRepository(runner);
452
+
453
+ await repository.attachOrCreateInteractiveSession(
454
+ issueUrl,
455
+ '/path/to/cl-scope-lib.sh',
456
+ );
457
+
458
+ expect(runner.runCommand.mock.calls).toHaveLength(1);
459
+ expect(runner.spawnInteractive.mock.calls).toEqual([
460
+ ['tmux', ['new-session', '-A', '-s', issueUrl, 'cl', issueUrl]],
461
+ ]);
462
+ });
463
+
464
+ it('creates a new session when the registered session is no longer alive', async () => {
465
+ const runner = createMockRunner();
466
+ runner.runCommand
467
+ .mockResolvedValueOnce({ stdout: 'wq7x3mk\n', stderr: '', exitCode: 0 })
468
+ .mockResolvedValueOnce({
469
+ stdout: '',
470
+ stderr: "can't find session",
471
+ exitCode: 1,
472
+ });
473
+ const repository = new NodeTmuxSessionRepository(runner);
474
+
475
+ await repository.attachOrCreateInteractiveSession(
476
+ issueUrl,
477
+ '/path/to/cl-scope-lib.sh',
478
+ );
479
+
480
+ expect(runner.runCommand.mock.calls[1]).toEqual([
481
+ 'tmux',
482
+ ['has-session', '-t', '=wq7x3mk'],
483
+ ]);
484
+ expect(runner.spawnInteractive.mock.calls).toEqual([
485
+ ['tmux', ['new-session', '-A', '-s', issueUrl, 'cl', issueUrl]],
486
+ ]);
487
+ });
488
+
489
+ it('creates a new session when the registry lookup fails', async () => {
490
+ const runner = createMockRunner();
491
+ runner.runCommand.mockResolvedValueOnce({
492
+ stdout: '',
493
+ stderr: 'registry error',
494
+ exitCode: 1,
495
+ });
496
+ const repository = new NodeTmuxSessionRepository(runner);
497
+
498
+ await repository.attachOrCreateInteractiveSession(
499
+ issueUrl,
500
+ '/path/to/cl-scope-lib.sh',
501
+ );
502
+
503
+ expect(runner.runCommand.mock.calls).toHaveLength(1);
504
+ expect(runner.spawnInteractive.mock.calls).toEqual([
505
+ ['tmux', ['new-session', '-A', '-s', issueUrl, 'cl', issueUrl]],
506
+ ]);
507
+ });
508
+
509
+ it('creates a new session without calling the registry when no scope lib path is provided', async () => {
510
+ const runner = createMockRunner();
511
+ const repository = new NodeTmuxSessionRepository(runner);
512
+
513
+ await repository.attachOrCreateInteractiveSession(issueUrl, null);
514
+
515
+ expect(runner.runCommand.mock.calls).toHaveLength(0);
516
+ expect(runner.spawnInteractive.mock.calls).toEqual([
517
+ ['tmux', ['new-session', '-A', '-s', issueUrl, 'cl', issueUrl]],
518
+ ]);
519
+ });
520
+
521
+ it('trims whitespace from the registered session name before checking tmux', async () => {
522
+ const runner = createMockRunner();
523
+ runner.runCommand
524
+ .mockResolvedValueOnce({
525
+ stdout: ' abc123 \n',
526
+ stderr: '',
527
+ exitCode: 0,
528
+ })
529
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
530
+ const repository = new NodeTmuxSessionRepository(runner);
531
+
532
+ await repository.attachOrCreateInteractiveSession(
533
+ issueUrl,
534
+ '/path/to/cl-scope-lib.sh',
535
+ );
536
+
537
+ expect(runner.runCommand.mock.calls[1]).toEqual([
538
+ 'tmux',
539
+ ['has-session', '-t', '=abc123'],
540
+ ]);
541
+ expect(runner.spawnInteractive.mock.calls).toEqual([
542
+ ['tmux', ['attach-session', '-t', '=abc123']],
543
+ ]);
544
+ });
545
+ });
414
546
  });
@@ -165,6 +165,43 @@ export class NodeTmuxSessionRepository implements TmuxSessionRepository {
165
165
  await this.sendEnter(sessionName);
166
166
  };
167
167
 
168
+ attachOrCreateInteractiveSession = async (
169
+ issueUrl: string,
170
+ scopeLibPath: string | null,
171
+ ): Promise<void> => {
172
+ if (scopeLibPath !== null) {
173
+ const { stdout, exitCode } = await this.localCommandRunner.runCommand(
174
+ 'bash',
175
+ [scopeLibPath, 'registry-get-session', issueUrl],
176
+ );
177
+ if (exitCode === 0 && stdout.trim().length > 0) {
178
+ const registeredSessionName = stdout.trim();
179
+ const { exitCode: hasSessionExitCode } =
180
+ await this.localCommandRunner.runCommand('tmux', [
181
+ 'has-session',
182
+ '-t',
183
+ `=${registeredSessionName}`,
184
+ ]);
185
+ if (hasSessionExitCode === 0) {
186
+ this.localCommandRunner.spawnInteractive('tmux', [
187
+ 'attach-session',
188
+ '-t',
189
+ `=${registeredSessionName}`,
190
+ ]);
191
+ return;
192
+ }
193
+ }
194
+ }
195
+ this.localCommandRunner.spawnInteractive('tmux', [
196
+ 'new-session',
197
+ '-A',
198
+ '-s',
199
+ issueUrl,
200
+ 'cl',
201
+ issueUrl,
202
+ ]);
203
+ };
204
+
168
205
  launchBareNameLeaderSession = async (name: string): Promise<void> => {
169
206
  const sessionName = name.replace(/[.:]/g, '_');
170
207
  const leaderCommand = `cl ${shellSingleQuote(name)}; exec /bin/bash`;
@@ -74,6 +74,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
74
74
 
75
75
  const futureReset = Math.floor(Date.now() / 1000) + 3600;
76
76
  const pastReset = Math.floor(Date.now() / 1000) - 3600;
77
+ const observedAfterReset = Math.floor(Date.now() / 1000) - 60;
77
78
 
78
79
  it('should map each token to its cached utilization and include name', async () => {
79
80
  mockLoadTokenEntries.mockReturnValue([
@@ -210,6 +211,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
210
211
  mockReadRateLimit.mockReturnValue({
211
212
  fiveHourUtilization: 100,
212
213
  fiveHourReset: pastReset,
214
+ lastUpdatedEpoch: observedAfterReset,
213
215
  sevenDayUtilization: 30,
214
216
  sevenDayReset: futureReset,
215
217
  blocked: false,
@@ -284,6 +286,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
284
286
  mockReadRateLimit.mockReturnValue({
285
287
  fiveHourUtilization: 100,
286
288
  fiveHourReset: pastReset,
289
+ lastUpdatedEpoch: observedAfterReset,
287
290
  sevenDayUtilization: 0,
288
291
  sevenDayReset: futureReset,
289
292
  blocked: false,
@@ -323,6 +326,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
323
326
  fiveHourReset: futureReset,
324
327
  sevenDayUtilization: 100,
325
328
  sevenDayReset: pastReset,
329
+ lastUpdatedEpoch: observedAfterReset,
326
330
  blocked: false,
327
331
  rejected: true,
328
332
  unifiedRejected: false,
@@ -393,6 +397,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
393
397
  mockReadRateLimit.mockReturnValue({
394
398
  fiveHourUtilization: 100,
395
399
  fiveHourReset: pastReset,
400
+ lastUpdatedEpoch: observedAfterReset,
396
401
  sevenDayUtilization: 100,
397
402
  sevenDayReset: futureReset,
398
403
  blocked: false,
@@ -430,6 +435,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
430
435
  mockReadRateLimit.mockReturnValue({
431
436
  fiveHourUtilization: 100,
432
437
  fiveHourReset: pastReset,
438
+ lastUpdatedEpoch: observedAfterReset,
433
439
  sevenDayUtilization: 0,
434
440
  sevenDayReset: futureReset,
435
441
  blocked: false,
@@ -734,6 +740,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
734
740
  fiveHourReset: futureReset,
735
741
  sevenDayUtilization: 0,
736
742
  sevenDayReset: pastReset,
743
+ lastUpdatedEpoch: observedAfterReset,
737
744
  blocked: false,
738
745
  rejected: false,
739
746
  unifiedRejected: false,
@@ -1130,3 +1137,34 @@ describe('ProxyClaudeTokenUsageRepository', () => {
1130
1137
  });
1131
1138
  });
1132
1139
  });
1140
+
1141
+ describe('ProxyClaudeTokenUsageRepository window rollover inference', () => {
1142
+ beforeEach(() => {
1143
+ jest.clearAllMocks();
1144
+ });
1145
+
1146
+ it('should keep the last observed sevenDayUtilization when the snapshot predates the 7d reset it reports', async () => {
1147
+ const pastReset = Math.floor(Date.now() / 1000) - 3600;
1148
+ const observedBeforeReset = pastReset - 72000;
1149
+ mockLoadTokenEntries.mockReturnValue([{ name: 'alice', token: 'token-a' }]);
1150
+ mockReadRateLimit.mockReturnValue({
1151
+ fiveHourUtilization: 0,
1152
+ fiveHourReset: Math.floor(Date.now() / 1000) + 3600,
1153
+ sevenDayUtilization: 98,
1154
+ sevenDayReset: pastReset,
1155
+ blocked: false,
1156
+ rejected: true,
1157
+ unifiedRejected: false,
1158
+ fiveHourRejected: false,
1159
+ sevenDayRejected: true,
1160
+ lastUpdatedEpoch: observedBeforeReset,
1161
+ modelWeeklyLimits: {},
1162
+ });
1163
+ const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
1164
+
1165
+ const result = await repository.getAvailableTokenUsages();
1166
+
1167
+ expect(result[0].sevenDayUtilization).toBe(98);
1168
+ expect(result[0].rejected).toBe(true);
1169
+ });
1170
+ });
@@ -43,8 +43,12 @@ export class ProxyClaudeTokenUsageRepository implements ClaudeTokenUsageReposito
43
43
  selectionWeight,
44
44
  };
45
45
  }
46
- const fiveHourExpired = nowEpochSeconds > snapshot.fiveHourReset;
47
- const sevenDayExpired = nowEpochSeconds > snapshot.sevenDayReset;
46
+ const fiveHourExpired =
47
+ nowEpochSeconds > snapshot.fiveHourReset &&
48
+ snapshot.lastUpdatedEpoch >= snapshot.fiveHourReset;
49
+ const sevenDayExpired =
50
+ nowEpochSeconds > snapshot.sevenDayReset &&
51
+ snapshot.lastUpdatedEpoch >= snapshot.sevenDayReset;
48
52
  const fiveHourUtilization = fiveHourExpired
49
53
  ? 0
50
54
  : snapshot.fiveHourUtilization;
@@ -10,6 +10,7 @@ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
10
10
  stderr: '',
11
11
  exitCode: 0,
12
12
  }),
13
+ spawnInteractive: jest.fn(),
13
14
  });
14
15
 
15
16
  const createMockSleeper = (): Mocked<Sleeper> => ({
@@ -42,6 +43,7 @@ const createPaneAwareRunner = (
42
43
  capturedPaneIndex += 1;
43
44
  return Promise.resolve({ stdout: paneText, stderr: '', exitCode: 0 });
44
45
  }),
46
+ spawnInteractive: jest.fn(),
45
47
  };
46
48
  };
47
49
 
@@ -164,6 +164,7 @@ describe('RevertOrphanedPreparationUseCase', () => {
164
164
  };
165
165
  mockLocalCommandRunner = {
166
166
  runCommand: jest.fn(),
167
+ spawnInteractive: jest.fn(),
167
168
  };
168
169
  useCase = new RevertOrphanedPreparationUseCase(
169
170
  mockProjectRepository,
@@ -132,6 +132,7 @@ describe('StartPreparationUseCase', () => {
132
132
  };
133
133
  mockLocalCommandRunner = {
134
134
  runCommand: jest.fn(),
135
+ spawnInteractive: jest.fn(),
135
136
  };
136
137
  mockClaudeTokenUsageRepository = {
137
138
  ensureObservable: jest.fn().mockResolvedValue(undefined),
@@ -6891,6 +6892,112 @@ describe('StartPreparationUseCase', () => {
6891
6892
  expect(storyUnsetWarningCalls).toHaveLength(0);
6892
6893
  consoleWarnSpy.mockRestore();
6893
6894
  });
6895
+
6896
+ describe('token in-flight count refresh', () => {
6897
+ it('refreshes the token in-flight counts once before abandoning a run so a freed slot is used', async () => {
6898
+ const awaitingIssue = createMockIssue({
6899
+ url: 'url1',
6900
+ title: 'Issue 1',
6901
+ labels: ['category:impl'],
6902
+ status: 'Awaiting Workspace',
6903
+ number: 1,
6904
+ itemId: 'item-1',
6905
+ });
6906
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
6907
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
6908
+ createMockStoryObjectMap([awaitingIssue]),
6909
+ );
6910
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
6911
+ stdout: '',
6912
+ stderr: '',
6913
+ exitCode: 0,
6914
+ });
6915
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
6916
+ {
6917
+ name: 'token-a',
6918
+ token: 'token-a',
6919
+ fiveHourUtilization: 0.1,
6920
+ sevenDayUtilization: 0.1,
6921
+ blocked: false,
6922
+ rejected: false,
6923
+ fiveHourRejected: false,
6924
+ blockedUntilEpoch: 0,
6925
+ modelWeeklyLimits: {},
6926
+ },
6927
+ ]);
6928
+ mockClaudeTokenUsageRepository.getTokenInFlightCounts
6929
+ .mockResolvedValueOnce({ 'token-a': 6 })
6930
+ .mockResolvedValueOnce({ 'token-a': 0 });
6931
+
6932
+ await useCase.run({
6933
+ projectUrl: 'https://github.com/user/repo',
6934
+ defaultAgentName: 'agent1',
6935
+ defaultLlmModelName: 'claude-opus',
6936
+ fallbackLlmModelName: null,
6937
+ defaultLlmAgentName: null,
6938
+ configFilePath: '/path/to/config.yml',
6939
+ maximumPreparingIssuesCount: null,
6940
+ utilizationPercentageThreshold: 90,
6941
+ allowedIssueAuthors: ['testuser'],
6942
+ manager: 'manager-user',
6943
+ codexHomeCandidates: null,
6944
+ labelsAsLlmAgentName: null,
6945
+ });
6946
+
6947
+ expect(
6948
+ mockClaudeTokenUsageRepository.getTokenInFlightCounts.mock.calls,
6949
+ ).toHaveLength(2);
6950
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
6951
+ });
6952
+
6953
+ it('abandons the run when the refreshed in-flight counts still leave no token slot', async () => {
6954
+ const awaitingIssue = createMockIssue({
6955
+ url: 'url1',
6956
+ title: 'Issue 1',
6957
+ labels: ['category:impl'],
6958
+ status: 'Awaiting Workspace',
6959
+ number: 1,
6960
+ itemId: 'item-1',
6961
+ });
6962
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
6963
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
6964
+ createMockStoryObjectMap([awaitingIssue]),
6965
+ );
6966
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
6967
+ {
6968
+ name: 'token-a',
6969
+ token: 'token-a',
6970
+ fiveHourUtilization: 0.1,
6971
+ sevenDayUtilization: 0.1,
6972
+ blocked: false,
6973
+ rejected: false,
6974
+ fiveHourRejected: false,
6975
+ blockedUntilEpoch: 0,
6976
+ modelWeeklyLimits: {},
6977
+ },
6978
+ ]);
6979
+ mockClaudeTokenUsageRepository.getTokenInFlightCounts.mockResolvedValue({
6980
+ 'token-a': 6,
6981
+ });
6982
+
6983
+ await useCase.run({
6984
+ projectUrl: 'https://github.com/user/repo',
6985
+ defaultAgentName: 'agent1',
6986
+ defaultLlmModelName: 'claude-opus',
6987
+ fallbackLlmModelName: null,
6988
+ defaultLlmAgentName: null,
6989
+ configFilePath: '/path/to/config.yml',
6990
+ maximumPreparingIssuesCount: null,
6991
+ utilizationPercentageThreshold: 90,
6992
+ allowedIssueAuthors: ['testuser'],
6993
+ manager: 'manager-user',
6994
+ codexHomeCandidates: null,
6995
+ labelsAsLlmAgentName: null,
6996
+ });
6997
+
6998
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
6999
+ });
7000
+ });
6894
7001
  });
6895
7002
 
6896
7003
  describe('StartPreparationUseCase.buildRotationOrder', () => {
@@ -6929,6 +7036,7 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
6929
7036
  };
6930
7037
  const mockLocalCommandRunnerForRotation: Mocked<LocalCommandRunner> = {
6931
7038
  runCommand: jest.fn(),
7039
+ spawnInteractive: jest.fn(),
6932
7040
  };
6933
7041
  const mockClaudeTokenUsageRepositoryForRotation: Mocked<ClaudeTokenUsageRepository> =
6934
7042
  {
@@ -7235,7 +7343,7 @@ describe('StartPreparationUseCase.getTokenConcurrentLimit', () => {
7235
7343
  setIssueAgentField: jest.fn(),
7236
7344
  removeLabel: jest.fn(),
7237
7345
  },
7238
- { runCommand: jest.fn() },
7346
+ { runCommand: jest.fn(), spawnInteractive: jest.fn() },
7239
7347
  {
7240
7348
  ensureObservable: jest.fn(),
7241
7349
  getAvailableTokenUsages: jest.fn(),
@@ -7326,7 +7434,7 @@ describe('StartPreparationUseCase.fetchSpawnCandidateBranchSources', () => {
7326
7434
  removeLabel: jest.fn(),
7327
7435
  ...issueRepositoryOverrides,
7328
7436
  },
7329
- { runCommand: jest.fn() },
7437
+ { runCommand: jest.fn(), spawnInteractive: jest.fn() },
7330
7438
  {
7331
7439
  ensureObservable: jest.fn(),
7332
7440
  getAvailableTokenUsages: jest.fn(),