github-issue-tower-defence-management 1.193.0 → 1.194.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +21 -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/NotifyFinishedIssuePreparationUseCase.js +8 -3
  16. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  17. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +4 -2
  18. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  19. package/bin/domain/usecases/StartPreparationUseCase.js +12 -2
  20. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  21. package/bin/domain/usecases/awaitingOwnerApprovalMessage.js +1 -1
  22. package/bin/domain/usecases/awaitingOwnerApprovalMessage.js.map +1 -1
  23. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +1 -3
  24. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
  25. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +12 -4
  26. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/adapter/entry-points/cli/index.test.ts +102 -0
  29. package/src/adapter/entry-points/cli/index.ts +43 -0
  30. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +45 -0
  31. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +7 -0
  32. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.stories.tsx +31 -0
  33. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.test.tsx +96 -0
  34. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.tsx +20 -2
  35. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +159 -0
  36. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +17 -1
  37. package/src/adapter/entry-points/console/ui-dist/assets/{index-RaFqjB7u.js → index-CYwLv9pf.js} +2 -2
  38. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  39. package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.test.ts +1 -0
  40. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +1 -0
  41. package/src/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.test.ts +4 -1
  42. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +2 -0
  43. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +1 -0
  44. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +1 -0
  45. package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.test.ts +4 -0
  46. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +1 -0
  47. package/src/adapter/repositories/NodeLocalCommandRunner.test.ts +16 -0
  48. package/src/adapter/repositories/NodeLocalCommandRunner.ts +5 -1
  49. package/src/adapter/repositories/NodeSubAgentProcessLister.test.ts +1 -0
  50. package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +132 -0
  51. package/src/adapter/repositories/NodeTmuxSessionRepository.ts +37 -0
  52. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +38 -0
  53. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +6 -2
  54. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +2 -0
  55. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +78 -4
  56. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +15 -3
  57. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +9 -2
  58. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +10 -2
  59. package/src/domain/usecases/StartPreparationUseCase.test.ts +110 -2
  60. package/src/domain/usecases/StartPreparationUseCase.ts +34 -17
  61. package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +1 -0
  62. package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +4 -0
  63. package/src/domain/usecases/awaitingOwnerApprovalMessage.ts +1 -1
  64. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +5 -4
  65. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +9 -11
  66. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +63 -0
  67. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +13 -4
  68. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +1 -0
  69. package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +1 -0
  70. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  71. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
  72. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts +1 -0
  73. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts.map +1 -1
  74. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +1 -0
  75. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
  76. package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
  77. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  78. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  79. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  80. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +1 -0
  81. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -1
  82. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +1 -0
  83. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
  84. package/types/domain/usecases/awaitingOwnerApprovalMessage.d.ts +1 -1
  85. package/types/domain/usecases/awaitingOwnerApprovalMessage.d.ts.map +1 -1
  86. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
  87. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +1 -0
  88. 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
 
@@ -38,6 +38,12 @@ const createMockProject = (overrides: Partial<Project> = {}): Project => ({
38
38
  color: 'BLUE',
39
39
  description: '',
40
40
  },
41
+ {
42
+ id: 'todo-by-human-id',
43
+ name: 'Todo by human',
44
+ color: 'GREEN',
45
+ description: '',
46
+ },
41
47
  ],
42
48
  },
43
49
  nextActionDate: null,
@@ -4669,7 +4675,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
4669
4675
  describe('waitingForOwnerApproval', () => {
4670
4676
  const issueUrl = 'https://github.com/user/repo/issues/1';
4671
4677
 
4672
- it('returns to Awaiting Workspace and posts AWAITING_OWNER_APPROVAL message when last report declares waitingForOwnerApproval', async () => {
4678
+ it('moves to Todo by human and posts AWAITING_OWNER_APPROVAL message when last report declares waitingForOwnerApproval', async () => {
4673
4679
  const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4674
4680
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
4675
4681
  mockIssueRepository.get.mockResolvedValue(issue);
@@ -4690,13 +4696,13 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
4690
4696
  });
4691
4697
 
4692
4698
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
4693
- expect.objectContaining({ status: 'Awaiting Workspace' }),
4699
+ expect.objectContaining({ status: 'Todo by human' }),
4694
4700
  mockProject,
4695
4701
  );
4696
4702
  expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
4697
4703
  mockProject,
4698
- expect.objectContaining({ status: 'Awaiting Workspace' }),
4699
- 'awaiting-workspace-id',
4704
+ expect.objectContaining({ status: 'Todo by human' }),
4705
+ 'todo-by-human-id',
4700
4706
  );
4701
4707
  expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
4702
4708
  expect.objectContaining({ url: issueUrl }),
@@ -4709,6 +4715,74 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
4709
4715
  );
4710
4716
  });
4711
4717
 
4718
+ it('does not return the issue to the dispatch pool when it is waiting for owner approval', async () => {
4719
+ const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4720
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
4721
+ mockIssueRepository.get.mockResolvedValue(issue);
4722
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
4723
+ createMockComment({
4724
+ content:
4725
+ 'From: :robot: systems-analyst (model)\n```json\n{"pullRequestRequired": false, "waitingForOwnerApproval": true}\n```',
4726
+ }),
4727
+ ]);
4728
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
4729
+
4730
+ await useCase.run({
4731
+ projectUrl: 'https://github.com/users/user/projects/1',
4732
+ issueUrl,
4733
+ thresholdForAutoReject: 3,
4734
+ workflowBlockerResolvedWebhookUrl: null,
4735
+ allowedIssueAuthors: null,
4736
+ });
4737
+
4738
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalledWith(
4739
+ mockProject,
4740
+ expect.anything(),
4741
+ 'awaiting-workspace-id',
4742
+ );
4743
+ });
4744
+
4745
+ it('escalates to Failed Preparation instead of the dispatch pool when the project has no Todo by human status', async () => {
4746
+ const projectWithoutTodo = {
4747
+ ...mockProject,
4748
+ status: {
4749
+ ...mockProject.status,
4750
+ statuses: mockProject.status.statuses.filter(
4751
+ (status) => status.name !== 'Todo by human',
4752
+ ),
4753
+ },
4754
+ };
4755
+ const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4756
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithoutTodo);
4757
+ mockIssueRepository.get.mockResolvedValue(issue);
4758
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
4759
+ createMockComment({
4760
+ content:
4761
+ 'From: :robot: systems-analyst (model)\n```json\n{"pullRequestRequired": false, "waitingForOwnerApproval": true}\n```',
4762
+ }),
4763
+ ]);
4764
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
4765
+
4766
+ await useCase.run({
4767
+ projectUrl: 'https://github.com/users/user/projects/1',
4768
+ issueUrl,
4769
+ thresholdForAutoReject: 3,
4770
+ workflowBlockerResolvedWebhookUrl: null,
4771
+ allowedIssueAuthors: null,
4772
+ });
4773
+
4774
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
4775
+ projectWithoutTodo,
4776
+ expect.objectContaining({ status: 'Failed Preparation' }),
4777
+ 'failed-preparation-id',
4778
+ );
4779
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalledWith(
4780
+ projectWithoutTodo,
4781
+ expect.anything(),
4782
+ 'awaiting-workspace-id',
4783
+ );
4784
+ });
4785
+
4712
4786
  it('escalates to Failed Preparation after ownerApprovalTimeoutCycles AWAITING_OWNER_APPROVAL messages', async () => {
4713
4787
  const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4714
4788
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
@@ -8,6 +8,7 @@ import {
8
8
  AWAITING_WORKSPACE_STATUS_NAME,
9
9
  FAILED_PREPARATION_STATUS_NAME,
10
10
  PREPARATION_STATUS_NAME,
11
+ TODO_STATUS_NAME,
11
12
  } from '../entities/WorkflowStatus';
12
13
  import {
13
14
  IssueRejectionEvaluator,
@@ -157,6 +158,9 @@ export class NotifyFinishedIssuePreparationUseCase {
157
158
  );
158
159
  return;
159
160
  }
161
+ const todoStatusOption = project.status.statuses.find(
162
+ (s) => s.name === TODO_STATUS_NAME,
163
+ );
160
164
 
161
165
  const issue = await this.issueRepository.get(params.issueUrl, project);
162
166
 
@@ -326,13 +330,21 @@ export class NotifyFinishedIssuePreparationUseCase {
326
330
  isTrustedAuthor(comment.author) &&
327
331
  comment.content.startsWith(AWAITING_OWNER_APPROVAL_MESSAGE_HEAD),
328
332
  ).length;
329
- if (awaitingOwnerApprovalCount < ownerApprovalTimeoutCycles) {
330
- issue.status = AWAITING_WORKSPACE_STATUS_NAME;
333
+ if (!todoStatusOption) {
334
+ console.error(
335
+ `Todo status option '${TODO_STATUS_NAME}' not found in project; escalating to '${FAILED_PREPARATION_STATUS_NAME}' instead of returning the task to the dispatch pool.`,
336
+ );
337
+ }
338
+ if (
339
+ awaitingOwnerApprovalCount < ownerApprovalTimeoutCycles &&
340
+ todoStatusOption
341
+ ) {
342
+ issue.status = TODO_STATUS_NAME;
331
343
  await this.issueRepository.update(issue, project);
332
344
  await this.issueRepository.updateStatus(
333
345
  project,
334
346
  issue,
335
- awaitingWorkspaceStatusOption.id,
347
+ todoStatusOption.id,
336
348
  );
337
349
  await this.patchConsoleTab(issue);
338
350
  await this.issueCommentRepository.createComment(