github-issue-tower-defence-management 1.149.1 → 1.151.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 (61) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +9 -1
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +24 -0
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +2 -2
  6. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +1 -1
  8. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js +13 -1
  10. package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js.map +1 -1
  11. package/bin/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.js +12 -0
  12. package/bin/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.js.map +1 -0
  13. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +60 -13
  14. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
  15. package/bin/domain/entities/UnansweredOwnerCall.js +3 -0
  16. package/bin/domain/entities/UnansweredOwnerCall.js.map +1 -0
  17. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +1 -0
  18. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -1
  19. package/bin/domain/usecases/OauthTokenSelectUseCase.js +15 -7
  20. package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
  21. package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js +21 -2
  22. package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js.map +1 -1
  23. package/package.json +1 -1
  24. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +46 -0
  25. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +21 -0
  26. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +6 -6
  27. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +27 -0
  28. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +5 -5
  29. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.test.ts +104 -1
  30. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.ts +19 -0
  31. package/src/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.test.ts +111 -0
  32. package/src/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.ts +33 -0
  33. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +178 -0
  34. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +106 -17
  35. package/src/domain/entities/UnansweredOwnerCall.ts +4 -0
  36. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +33 -0
  37. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +4 -2
  38. package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +88 -0
  39. package/src/domain/usecases/OauthTokenSelectUseCase.ts +21 -13
  40. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.test.ts +99 -0
  41. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.ts +47 -1
  42. package/src/domain/usecases/intmux/UnansweredOwnerCallSessionKeyContract.test.ts +141 -0
  43. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  44. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +2 -2
  45. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -1
  46. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts +1 -1
  47. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -1
  48. package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts +2 -0
  49. package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts.map +1 -1
  50. package/types/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.d.ts +13 -0
  51. package/types/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.d.ts.map +1 -0
  52. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +5 -0
  53. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
  54. package/types/domain/entities/UnansweredOwnerCall.d.ts +5 -0
  55. package/types/domain/entities/UnansweredOwnerCall.d.ts.map +1 -0
  56. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +3 -2
  57. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -1
  58. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +1 -0
  59. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
  60. package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts +19 -0
  61. package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts.map +1 -1
@@ -820,6 +820,184 @@ describe('TranscriptOwnerCallStatusProvider', () => {
820
820
  Math.floor(Date.parse('2026-06-27T10:20:00.000Z') / 1000),
821
821
  );
822
822
  });
823
+
824
+ describe('listUnansweredOwnerCallsBySessionName', () => {
825
+ const assistantCallSaying = (timestamp: string, text: string): object => ({
826
+ type: 'assistant',
827
+ timestamp,
828
+ message: {
829
+ role: 'assistant',
830
+ stop_reason: 'end_turn',
831
+ content: [{ type: 'text', text: `${text} <<OWNER_CALL>>` }],
832
+ },
833
+ });
834
+
835
+ it('returns the time and the body of an unanswered owner call', async () => {
836
+ const transcriptPath = writeTranscript('workbench.jsonl', [
837
+ ownerReply('2026-06-27T09:00:00.000Z'),
838
+ assistantCallSaying(
839
+ '2026-06-27T10:00:00.000Z',
840
+ 'Please decide whether to merge the release branch',
841
+ ),
842
+ ]);
843
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
844
+
845
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
846
+ new Map([[sessionName, transcriptPath]]),
847
+ );
848
+
849
+ expect(result.get(sessionName)).toEqual([
850
+ {
851
+ calledAt: '2026-06-27T10:00:00.000Z',
852
+ body: 'Please decide whether to merge the release branch <<OWNER_CALL>>',
853
+ },
854
+ ]);
855
+ });
856
+
857
+ it('returns every owner call raised since the last owner reply, oldest first', async () => {
858
+ const transcriptPath = writeTranscript('workbench.jsonl', [
859
+ assistantCallSaying('2026-06-27T08:00:00.000Z', 'answered call'),
860
+ ownerReply('2026-06-27T09:00:00.000Z'),
861
+ assistantCallSaying('2026-06-27T10:00:00.000Z', 'first waiting call'),
862
+ assistantCallSaying('2026-06-27T11:30:00.000Z', 'second waiting call'),
863
+ ]);
864
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
865
+
866
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
867
+ new Map([[sessionName, transcriptPath]]),
868
+ );
869
+
870
+ expect(
871
+ result.get(sessionName)?.map((ownerCall) => ownerCall.calledAt),
872
+ ).toEqual(['2026-06-27T10:00:00.000Z', '2026-06-27T11:30:00.000Z']);
873
+ expect(result.get(sessionName)?.[0].body).toContain('first waiting call');
874
+ expect(result.get(sessionName)?.[1].body).toContain(
875
+ 'second waiting call',
876
+ );
877
+ });
878
+
879
+ it('gives each call of one session a distinct time so the calls stay separable', async () => {
880
+ const transcriptPath = writeTranscript('workbench.jsonl', [
881
+ assistantCallSaying('2026-06-27T10:00:00.000Z', 'same text'),
882
+ assistantCallSaying('2026-06-27T11:30:00.000Z', 'same text'),
883
+ ]);
884
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
885
+
886
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
887
+ new Map([[sessionName, transcriptPath]]),
888
+ );
889
+
890
+ const calls = result.get(sessionName) ?? [];
891
+ expect(calls).toHaveLength(2);
892
+ expect(calls[0].body).toBe(calls[1].body);
893
+ expect(calls[0].calledAt).not.toBe(calls[1].calledAt);
894
+ });
895
+
896
+ it('keeps a delivered call listed while the newest call is a candidate the status line has not rendered yet', async () => {
897
+ const transcriptPath = writeTranscript('workbench.jsonl', [
898
+ ownerReply('2026-06-27T09:00:00.000Z'),
899
+ assistantCallSaying('2026-06-27T10:00:00.000Z', 'delivered call'),
900
+ assistantWithCandidateMarker('2026-06-27T11:00:00.000Z'),
901
+ ]);
902
+ const markerDirectory = path.join(rootDirectory, 'markers');
903
+ fs.mkdirSync(markerDirectory, { recursive: true });
904
+ const provider = new TranscriptOwnerCallStatusProvider(
905
+ '<<OWNER_CALL>>',
906
+ markerDirectory,
907
+ );
908
+
909
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
910
+ new Map([[sessionName, transcriptPath]]),
911
+ );
912
+
913
+ expect(
914
+ result.get(sessionName)?.map((ownerCall) => ownerCall.calledAt),
915
+ ).toEqual(['2026-06-27T10:00:00.000Z']);
916
+ });
917
+
918
+ it('omits a call the format gate held and never approved from the listed calls', async () => {
919
+ const transcriptPath = writeTranscript('workbench.jsonl', [
920
+ ownerReply('2026-06-27T09:00:00.000Z'),
921
+ assistantCallSaying('2026-06-27T10:00:00.000Z', 'held by the gate'),
922
+ assistantCallSaying('2026-06-27T11:00:00.000Z', 'delivered call'),
923
+ ]);
924
+ const markerDirectory = path.join(rootDirectory, 'markers');
925
+ writeCallGateMarker(
926
+ markerDirectory,
927
+ 'workbench.jsonl',
928
+ '.suppress_ts',
929
+ '2026-06-27T10:00:00.000Z',
930
+ );
931
+ const provider = new TranscriptOwnerCallStatusProvider(
932
+ '<<OWNER_CALL>>',
933
+ markerDirectory,
934
+ );
935
+
936
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
937
+ new Map([[sessionName, transcriptPath]]),
938
+ );
939
+
940
+ expect(
941
+ result.get(sessionName)?.map((ownerCall) => ownerCall.calledAt),
942
+ ).toEqual(['2026-06-27T11:00:00.000Z']);
943
+ });
944
+
945
+ it('lists the calls oldest first even when the transcript records a later call before an earlier one', async () => {
946
+ const transcriptPath = writeTranscript('workbench.jsonl', [
947
+ assistantCallSaying('2026-06-27T11:00:00.000Z', 'later call'),
948
+ assistantCallSaying('2026-06-27T10:00:00.000Z', 'earlier call'),
949
+ ]);
950
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
951
+
952
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
953
+ new Map([[sessionName, transcriptPath]]),
954
+ );
955
+
956
+ expect(
957
+ result.get(sessionName)?.map((ownerCall) => ownerCall.calledAt),
958
+ ).toEqual(['2026-06-27T10:00:00.000Z', '2026-06-27T11:00:00.000Z']);
959
+ });
960
+
961
+ it('returns no entry for a session whose last owner call the owner already answered', async () => {
962
+ const transcriptPath = writeTranscript('workbench.jsonl', [
963
+ assistantCallSaying('2026-06-27T10:00:00.000Z', 'please decide'),
964
+ ownerReply('2026-06-27T10:30:00.000Z'),
965
+ ]);
966
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
967
+
968
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
969
+ new Map([[sessionName, transcriptPath]]),
970
+ );
971
+
972
+ expect(result.has(sessionName)).toBe(false);
973
+ });
974
+
975
+ it('returns no entry for a session that raised no owner call', async () => {
976
+ const transcriptPath = writeTranscript('workbench.jsonl', [
977
+ assistantPlain('2026-06-27T10:00:00.000Z'),
978
+ ]);
979
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
980
+
981
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
982
+ new Map([[sessionName, transcriptPath]]),
983
+ );
984
+
985
+ expect(result.has(sessionName)).toBe(false);
986
+ });
987
+
988
+ it('returns nothing when no owner call marker is configured', async () => {
989
+ const transcriptPath = writeTranscript('workbench.jsonl', [
990
+ assistantCallSaying('2026-06-27T10:00:00.000Z', 'please decide'),
991
+ ]);
992
+ const provider = new TranscriptOwnerCallStatusProvider(null);
993
+
994
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
995
+ new Map([[sessionName, transcriptPath]]),
996
+ );
997
+
998
+ expect(result.size).toBe(0);
999
+ });
1000
+ });
823
1001
  });
824
1002
 
825
1003
  describe('ownerCallMarkerFamilyResolve', () => {
@@ -1,8 +1,20 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { OwnerCallStatusProvider } from '../../domain/usecases/adapter-interfaces/OwnerCallStatusProvider';
4
+ import { UnansweredOwnerCall } from '../../domain/entities/UnansweredOwnerCall';
4
5
  import { SILENT_SESSION_REMINDER_SENTINEL } from '../../domain/usecases/silentSessionReminderSentinel';
5
6
 
7
+ type TranscriptOwnerCall = {
8
+ epochMs: number;
9
+ body: string;
10
+ candidateOnly: boolean;
11
+ };
12
+
13
+ type TranscriptOwnerCallScan = {
14
+ ownerCalls: TranscriptOwnerCall[];
15
+ lastOwnerReplyEpochMs: number | null;
16
+ };
17
+
6
18
  const isRecord = (value: unknown): value is Record<string, unknown> =>
7
19
  typeof value === 'object' && value !== null;
8
20
 
@@ -181,18 +193,58 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
181
193
  return unansweredOwnerCallEpochSecondsBySessionName;
182
194
  };
183
195
 
184
- private findUnansweredOwnerCallEpochMs = (
196
+ listUnansweredOwnerCallsBySessionName = async (
197
+ transcriptPathBySessionName: Map<string, string>,
198
+ ): Promise<Map<string, UnansweredOwnerCall[]>> => {
199
+ const unansweredOwnerCallsBySessionName = new Map<
200
+ string,
201
+ UnansweredOwnerCall[]
202
+ >();
203
+ if (this.ownerCallMarkerFamily.length === 0) {
204
+ return unansweredOwnerCallsBySessionName;
205
+ }
206
+ for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
207
+ const transcript = this.scanTranscript(
208
+ transcriptPath,
209
+ this.ownerCallMarkerFamily,
210
+ );
211
+ if (transcript === null) {
212
+ continue;
213
+ }
214
+ const answeredBeforeEpochMs = this.resolveReplyEpochMs(
215
+ transcriptPath,
216
+ transcript.lastOwnerReplyEpochMs,
217
+ );
218
+ const unansweredCalls = transcript.ownerCalls
219
+ .filter(
220
+ (ownerCall) =>
221
+ (answeredBeforeEpochMs === null ||
222
+ ownerCall.epochMs > answeredBeforeEpochMs) &&
223
+ this.isCallDeliveredToOwner(transcriptPath, ownerCall),
224
+ )
225
+ .sort((oneCall, otherCall) => oneCall.epochMs - otherCall.epochMs)
226
+ .map((ownerCall) => ({
227
+ calledAt: new Date(ownerCall.epochMs).toISOString(),
228
+ body: ownerCall.body,
229
+ }));
230
+ if (unansweredCalls.length > 0) {
231
+ unansweredOwnerCallsBySessionName.set(sessionName, unansweredCalls);
232
+ }
233
+ }
234
+ return unansweredOwnerCallsBySessionName;
235
+ };
236
+
237
+ private scanTranscript = (
185
238
  transcriptPath: string,
186
239
  markerFamily: string[],
187
- ): number | null => {
240
+ ): TranscriptOwnerCallScan | null => {
188
241
  let content: string;
189
242
  try {
190
243
  content = fs.readFileSync(transcriptPath, 'utf8');
191
244
  } catch {
192
245
  return null;
193
246
  }
194
- let lastOwnerCallEpochMs: number | null = null;
195
- let lastOwnerCallIsCandidateOnly = false;
247
+ const ownerCalls: TranscriptOwnerCall[] = [];
196
248
  let lastOwnerReplyEpochMs: number | null = null;
197
249
  for (const line of content.split('\n')) {
198
250
  const trimmed = line.trim();
@@ -221,10 +273,11 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
221
273
  assistantText.includes(marker),
222
274
  );
223
275
  if (matchedMarkers.length > 0) {
224
- lastOwnerCallEpochMs = epochMs;
225
- lastOwnerCallIsCandidateOnly = matchedMarkers.every(
226
- isCandidateOwnerCallMarker,
227
- );
276
+ ownerCalls.push({
277
+ epochMs,
278
+ body: assistantText,
279
+ candidateOnly: matchedMarkers.every(isCandidateOwnerCallMarker),
280
+ });
228
281
  }
229
282
  }
230
283
  if (
@@ -241,27 +294,50 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
241
294
  : lastOwnerReplyEpochMs;
242
295
  }
243
296
  }
244
- if (lastOwnerCallEpochMs === null) {
297
+ return { ownerCalls, lastOwnerReplyEpochMs };
298
+ };
299
+
300
+ private resolveReplyEpochMs = (
301
+ transcriptPath: string,
302
+ lastOwnerReplyEpochMs: number | null,
303
+ ): number | null => {
304
+ const markerReplyEpochMs = this.readOwnerReplyMarkerEpochMs(transcriptPath);
305
+ return markerReplyEpochMs !== null &&
306
+ (lastOwnerReplyEpochMs === null ||
307
+ markerReplyEpochMs > lastOwnerReplyEpochMs)
308
+ ? markerReplyEpochMs
309
+ : lastOwnerReplyEpochMs;
310
+ };
311
+
312
+ private findUnansweredOwnerCallEpochMs = (
313
+ transcriptPath: string,
314
+ markerFamily: string[],
315
+ ): number | null => {
316
+ const transcript = this.scanTranscript(transcriptPath, markerFamily);
317
+ if (transcript === null) {
245
318
  return null;
246
319
  }
320
+ const lastOwnerCall =
321
+ transcript.ownerCalls[transcript.ownerCalls.length - 1] ?? null;
322
+ if (lastOwnerCall === null) {
323
+ return null;
324
+ }
325
+ const lastOwnerCallEpochMs = lastOwnerCall.epochMs;
247
326
  if (
248
327
  this.isCallSuppressedUndelivered(transcriptPath, lastOwnerCallEpochMs)
249
328
  ) {
250
329
  return null;
251
330
  }
252
331
  if (
253
- lastOwnerCallIsCandidateOnly &&
332
+ lastOwnerCall.candidateOnly &&
254
333
  !this.isCandidateCallDelivered(transcriptPath, lastOwnerCallEpochMs)
255
334
  ) {
256
335
  return null;
257
336
  }
258
- const markerReplyEpochMs = this.readOwnerReplyMarkerEpochMs(transcriptPath);
259
- const resolvedReplyEpochMs =
260
- markerReplyEpochMs !== null &&
261
- (lastOwnerReplyEpochMs === null ||
262
- markerReplyEpochMs > lastOwnerReplyEpochMs)
263
- ? markerReplyEpochMs
264
- : lastOwnerReplyEpochMs;
337
+ const resolvedReplyEpochMs = this.resolveReplyEpochMs(
338
+ transcriptPath,
339
+ transcript.lastOwnerReplyEpochMs,
340
+ );
265
341
  return resolvedReplyEpochMs === null ||
266
342
  lastOwnerCallEpochMs > resolvedReplyEpochMs
267
343
  ? lastOwnerCallEpochMs
@@ -285,6 +361,19 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
285
361
  // on the owner would silence the session's stall reminder for good — the session would keep its
286
362
  // task and never be woken again. Such a call is therefore not an outstanding owner call here. A
287
363
  // delivered call, and a newer call the suppression marker does not name, are untouched.
364
+ private isCallDeliveredToOwner = (
365
+ transcriptPath: string,
366
+ ownerCall: TranscriptOwnerCall,
367
+ ): boolean => {
368
+ if (this.isCallSuppressedUndelivered(transcriptPath, ownerCall.epochMs)) {
369
+ return false;
370
+ }
371
+ return (
372
+ !ownerCall.candidateOnly ||
373
+ this.isCandidateCallDelivered(transcriptPath, ownerCall.epochMs)
374
+ );
375
+ };
376
+
288
377
  private isCallSuppressedUndelivered = (
289
378
  transcriptPath: string,
290
379
  ownerCallEpochMs: number,
@@ -0,0 +1,4 @@
1
+ export type UnansweredOwnerCall = {
2
+ calledAt: string;
3
+ body: string;
4
+ };
@@ -476,6 +476,39 @@ describe('LiveSessionOauthTokenSelectUseCase', () => {
476
476
  });
477
477
  });
478
478
 
479
+ describe('LiveSessionOauthTokenCandidateMetrics selectionWeight', () => {
480
+ const useCase = new LiveSessionOauthTokenSelectUseCase();
481
+
482
+ it('includes the configured selection weight in each candidate metric', () => {
483
+ const result = useCase.run(
484
+ [
485
+ withSelectionWeight(candidate('heavy', snapshot({})), 2),
486
+ candidate('normal', snapshot({})),
487
+ ],
488
+ [],
489
+ NOW,
490
+ SETTINGS,
491
+ );
492
+
493
+ const heavy = result.metrics.find((m) => m.name === 'heavy');
494
+ const normal = result.metrics.find((m) => m.name === 'normal');
495
+ expect(heavy?.selectionWeight).toBe(2);
496
+ expect(normal?.selectionWeight).toBe(1);
497
+ });
498
+
499
+ it('reports selection weight one for a candidate without an explicit selectionWeight', () => {
500
+ const result = useCase.run(
501
+ [candidate('implicit', snapshot({}))],
502
+ [],
503
+ NOW,
504
+ SETTINGS,
505
+ );
506
+
507
+ const implicit = result.metrics.find((m) => m.name === 'implicit');
508
+ expect(implicit?.selectionWeight).toBe(1);
509
+ });
510
+ });
511
+
479
512
  describe('liveSessionConcurrentLimitOf', () => {
480
513
  it('gives the full limit while the five hour window is at or above the full speed free ratio', () => {
481
514
  expect(liveSessionConcurrentLimitOf(1, 1, SETTINGS)).toBe(
@@ -1,6 +1,6 @@
1
- import { ClaudeLiveSession } from './adapter-interfaces/ClaudeLiveSessionRepository';
1
+ import type { ClaudeLiveSession } from './adapter-interfaces/ClaudeLiveSessionRepository';
2
2
  import {
3
- OauthTokenCandidate,
3
+ type OauthTokenCandidate,
4
4
  OauthTokenSelectUseCase,
5
5
  selectionWeightOf,
6
6
  } from './OauthTokenSelectUseCase';
@@ -45,6 +45,7 @@ export type LiveSessionOauthTokenCandidateMetrics = {
45
45
  hasConcurrencyHeadroom: boolean;
46
46
  eligible: boolean;
47
47
  exclusionReason: string | null;
48
+ selectionWeight: number;
48
49
  };
49
50
 
50
51
  export type LiveSessionOauthTokenSelectResult = {
@@ -91,6 +92,7 @@ export class LiveSessionOauthTokenSelectUseCase {
91
92
  hasConcurrencyHeadroom: liveSessionCount < concurrentSessionLimit,
92
93
  eligible: rateLimitMetric.eligible,
93
94
  exclusionReason: rateLimitMetric.exclusionReason,
95
+ selectionWeight: selectionWeightOf(candidate),
94
96
  },
95
97
  };
96
98
  });
@@ -439,6 +439,94 @@ describe('OauthTokenSelectUseCase selectionWeight', () => {
439
439
  });
440
440
  });
441
441
 
442
+ describe('OauthTokenCandidateMetrics drawWeight', () => {
443
+ const useCase = new OauthTokenSelectUseCase();
444
+
445
+ it('reports draw weight zero for an ineligible candidate', () => {
446
+ const result = useCase.run(
447
+ [
448
+ candidate('busy5h', snapshot({ fiveHourUtilization: 0.9 })),
449
+ candidate('ok', snapshot({})),
450
+ ],
451
+ NOW,
452
+ );
453
+
454
+ const busy = result.metrics.find((m) => m.name === 'busy5h');
455
+ expect(busy?.drawWeight).toBe(0);
456
+ });
457
+
458
+ it('reports a positive draw weight for an eligible candidate', () => {
459
+ const result = useCase.run(
460
+ [candidate('eligible', snapshot({ sevenDayReset: NOW + 2 * DAY }))],
461
+ NOW,
462
+ );
463
+
464
+ const eligible = result.metrics.find((m) => m.name === 'eligible');
465
+ expect(eligible?.drawWeight).toBeGreaterThan(0);
466
+ });
467
+
468
+ it('gives a higher draw weight to a candidate whose 7d window resets sooner', () => {
469
+ const result = useCase.run(
470
+ [
471
+ candidate('soon', snapshot({ sevenDayReset: NOW + 2 * DAY })),
472
+ candidate('far', snapshot({ sevenDayReset: NOW + 6 * DAY })),
473
+ ],
474
+ NOW,
475
+ );
476
+
477
+ const soon = result.metrics.find((m) => m.name === 'soon');
478
+ const far = result.metrics.find((m) => m.name === 'far');
479
+ expect(soon?.drawWeight ?? 0).toBeGreaterThan(far?.drawWeight ?? 0);
480
+ });
481
+
482
+ it('scales draw weight by the configured selection weight', () => {
483
+ const result = useCase.run(
484
+ [
485
+ withSelectionWeight(
486
+ candidate('double', snapshot({ sevenDayReset: NOW + 2 * DAY })),
487
+ 2,
488
+ ),
489
+ withSelectionWeight(
490
+ candidate('single', snapshot({ sevenDayReset: NOW + 2 * DAY })),
491
+ 1,
492
+ ),
493
+ ],
494
+ NOW,
495
+ );
496
+
497
+ const double = result.metrics.find((m) => m.name === 'double');
498
+ const single = result.metrics.find((m) => m.name === 'single');
499
+ expect(double?.drawWeight).toBeCloseTo((single?.drawWeight ?? 0) * 2, 5);
500
+ });
501
+
502
+ it('draws on exactly the weight it reports, so the trace cannot disagree with the draw', () => {
503
+ const candidates = [
504
+ withSelectionWeight(
505
+ candidate('double', snapshot({ sevenDayReset: NOW + 2 * DAY })),
506
+ 2,
507
+ ),
508
+ withSelectionWeight(
509
+ candidate('single', snapshot({ sevenDayReset: NOW + 2 * DAY })),
510
+ 1,
511
+ ),
512
+ ];
513
+
514
+ const reported = useCase.run(candidates, NOW, () => 0).metrics;
515
+ const doubleWeight =
516
+ reported.find((m) => m.name === 'double')?.drawWeight ?? 0;
517
+ const singleWeight =
518
+ reported.find((m) => m.name === 'single')?.drawWeight ?? 0;
519
+ const boundary = doubleWeight / (doubleWeight + singleWeight);
520
+
521
+ expect(
522
+ useCase.run(candidates, NOW, () => boundary - 0.01).selected?.name,
523
+ ).toBe('double');
524
+ expect(
525
+ useCase.run(candidates, NOW, () => boundary + 0.01).selected?.name,
526
+ ).toBe('single');
527
+ });
528
+ });
529
+
442
530
  describe('sevenDayUrgencyFactor', () => {
443
531
  const now = 1_000_000;
444
532
 
@@ -31,6 +31,7 @@ export type OauthTokenCandidateMetrics = {
31
31
  sevenDayEndEpoch: number;
32
32
  eligible: boolean;
33
33
  exclusionReason: string | null;
34
+ drawWeight: number;
34
35
  };
35
36
 
36
37
  export type OauthTokenSelectResult = {
@@ -91,13 +92,26 @@ export class OauthTokenSelectUseCase {
91
92
  nowEpochSeconds: number,
92
93
  random: SelectionRandom = Math.random,
93
94
  ): OauthTokenSelectResult => {
94
- const evaluated = candidates.map((candidate) => ({
95
- candidate,
96
- metric: this.evaluate(candidate, nowEpochSeconds),
97
- }));
95
+ const evaluated = candidates.map((candidate) => {
96
+ const evaluatedMetric = this.evaluate(candidate, nowEpochSeconds);
97
+ return {
98
+ candidate,
99
+ metric: {
100
+ ...evaluatedMetric,
101
+ drawWeight: evaluatedMetric.eligible
102
+ ? selectionWeightOf(candidate) *
103
+ sevenDayUrgencyFactor(
104
+ evaluatedMetric.sevenDayFreeRatio,
105
+ evaluatedMetric.sevenDayEndEpoch,
106
+ nowEpochSeconds,
107
+ )
108
+ : 0,
109
+ },
110
+ };
111
+ });
98
112
 
99
- const metrics = evaluated.map((entry) => entry.metric);
100
113
  const eligible = evaluated.filter((entry) => entry.metric.eligible);
114
+ const metrics = evaluated.map((entry) => entry.metric);
101
115
 
102
116
  if (eligible.length === 0) {
103
117
  return { selected: null, metrics };
@@ -111,13 +125,7 @@ export class OauthTokenSelectUseCase {
111
125
 
112
126
  const selected = selectWeightedCandidate(
113
127
  eligible,
114
- (entry) =>
115
- selectionWeightOf(entry.candidate) *
116
- sevenDayUrgencyFactor(
117
- entry.metric.sevenDayFreeRatio,
118
- entry.metric.sevenDayEndEpoch,
119
- nowEpochSeconds,
120
- ),
128
+ (entry) => entry.metric.drawWeight,
121
129
  deterministicBest,
122
130
  random,
123
131
  );
@@ -128,7 +136,7 @@ export class OauthTokenSelectUseCase {
128
136
  private evaluate = (
129
137
  candidate: OauthTokenCandidate,
130
138
  nowEpochSeconds: number,
131
- ): OauthTokenCandidateMetrics => {
139
+ ): Omit<OauthTokenCandidateMetrics, 'drawWeight'> => {
132
140
  const fiveHourFreeRatio = this.fiveHourFreeRatio(
133
141
  candidate.snapshot,
134
142
  nowEpochSeconds,