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.
- package/CHANGELOG.md +19 -0
- package/README.md +9 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +24 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +2 -2
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +1 -1
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js +13 -1
- package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js.map +1 -1
- package/bin/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.js +12 -0
- package/bin/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.js.map +1 -0
- package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +60 -13
- package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
- package/bin/domain/entities/UnansweredOwnerCall.js +3 -0
- package/bin/domain/entities/UnansweredOwnerCall.js.map +1 -0
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +1 -0
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -1
- package/bin/domain/usecases/OauthTokenSelectUseCase.js +15 -7
- package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
- package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js +21 -2
- package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +46 -0
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +21 -0
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +6 -6
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +27 -0
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +5 -5
- package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.test.ts +104 -1
- package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.ts +19 -0
- package/src/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.test.ts +111 -0
- package/src/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.ts +33 -0
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +178 -0
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +106 -17
- package/src/domain/entities/UnansweredOwnerCall.ts +4 -0
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +33 -0
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +4 -2
- package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +88 -0
- package/src/domain/usecases/OauthTokenSelectUseCase.ts +21 -13
- package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.test.ts +99 -0
- package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.ts +47 -1
- package/src/domain/usecases/intmux/UnansweredOwnerCallSessionKeyContract.test.ts +141 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +2 -2
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts +1 -1
- package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts +2 -0
- package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.d.ts +13 -0
- package/types/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.d.ts.map +1 -0
- package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +5 -0
- package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
- package/types/domain/entities/UnansweredOwnerCall.d.ts +5 -0
- package/types/domain/entities/UnansweredOwnerCall.d.ts.map +1 -0
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +3 -2
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -1
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +1 -0
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
- package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts +19 -0
- 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
|
-
|
|
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
|
-
):
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
+
lastOwnerCall.candidateOnly &&
|
|
254
333
|
!this.isCandidateCallDelivered(transcriptPath, lastOwnerCallEpochMs)
|
|
255
334
|
) {
|
|
256
335
|
return null;
|
|
257
336
|
}
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
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,
|