github-issue-tower-defence-management 1.132.0 → 1.133.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 +14 -0
- package/README.md +7 -7
- package/bin/adapter/entry-points/cli/index.js +4 -4
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +2 -1
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +2 -1
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -1
- package/bin/adapter/proxy/TokenListLoader.js +4 -5
- package/bin/adapter/proxy/TokenListLoader.js.map +1 -1
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js +19 -0
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
- package/bin/adapter/repositories/clSessionScopeUnitName.js +10 -0
- package/bin/adapter/repositories/clSessionScopeUnitName.js.map +1 -0
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +5 -4
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -1
- package/bin/domain/usecases/OauthTokenSelectUseCase.js +31 -4
- package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.ts +4 -4
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +28 -24
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +28 -24
- package/src/adapter/proxy/TokenListLoader.test.ts +52 -0
- package/src/adapter/proxy/TokenListLoader.ts +25 -5
- package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +55 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.ts +27 -0
- package/src/adapter/repositories/clSessionScopeUnitName.test.ts +76 -0
- package/src/adapter/repositories/clSessionScopeUnitName.ts +5 -0
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +74 -0
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +13 -2
- package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +162 -0
- package/src/domain/usecases/OauthTokenSelectUseCase.ts +47 -2
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -1
- package/types/adapter/proxy/TokenListLoader.d.ts +1 -0
- package/types/adapter/proxy/TokenListLoader.d.ts.map +1 -1
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +1 -0
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
- package/types/adapter/repositories/clSessionScopeUnitName.d.ts +2 -0
- package/types/adapter/repositories/clSessionScopeUnitName.d.ts.map +1 -0
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +2 -2
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -1
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +6 -1
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
LiveSessionOauthTokenSelectUseCase,
|
|
5
5
|
} from '../../../domain/usecases/LiveSessionOauthTokenSelectUseCase';
|
|
6
6
|
import {
|
|
7
|
+
DEFAULT_SELECTION_WEIGHT,
|
|
7
8
|
FIVE_HOUR_MIN_FREE_RATIO,
|
|
8
9
|
OauthTokenCandidate,
|
|
9
10
|
SEVEN_DAY_MIN_FREE_RATIO,
|
|
@@ -61,30 +62,33 @@ export class LiveSessionOauthTokenSelectHandler {
|
|
|
61
62
|
|
|
62
63
|
const cacheDirectory = resolveCacheDirectory(input.cacheDirectory);
|
|
63
64
|
|
|
64
|
-
const candidates: OauthTokenCandidate[] = entries.map(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
snapshot
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
65
|
+
const candidates: OauthTokenCandidate[] = entries.map(
|
|
66
|
+
({ name, token, selectionWeight }) => {
|
|
67
|
+
const snapshot = readRateLimit(token, cacheDirectory);
|
|
68
|
+
const fableLimit = snapshot?.modelWeeklyLimits[FABLE_LIMIT_TYPE];
|
|
69
|
+
const fableRejected =
|
|
70
|
+
fableLimit !== undefined &&
|
|
71
|
+
fableLimit.rejected &&
|
|
72
|
+
input.nowEpochSeconds <= fableLimit.resetsAt;
|
|
73
|
+
return {
|
|
74
|
+
name,
|
|
75
|
+
token,
|
|
76
|
+
snapshot:
|
|
77
|
+
snapshot === null
|
|
78
|
+
? null
|
|
79
|
+
: {
|
|
80
|
+
fiveHourUtilization: snapshot.fiveHourUtilization,
|
|
81
|
+
fiveHourReset: snapshot.fiveHourReset,
|
|
82
|
+
sevenDayUtilization: snapshot.sevenDayUtilization,
|
|
83
|
+
sevenDayReset: snapshot.sevenDayReset,
|
|
84
|
+
},
|
|
85
|
+
subscriptionDisabled: snapshot?.subscriptionDisabled ?? false,
|
|
86
|
+
unifiedRejected: snapshot?.unifiedRejected ?? false,
|
|
87
|
+
fableRejected,
|
|
88
|
+
selectionWeight: selectionWeight ?? DEFAULT_SELECTION_WEIGHT,
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
);
|
|
88
92
|
|
|
89
93
|
const liveSessions = this.liveSessionRepository.listLiveSessions();
|
|
90
94
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
DEFAULT_SELECTION_WEIGHT,
|
|
2
3
|
OauthTokenCandidate,
|
|
3
4
|
OauthTokenSelectResult,
|
|
4
5
|
OauthTokenSelectUseCase,
|
|
@@ -83,30 +84,33 @@ export class OauthTokenSelectHandler {
|
|
|
83
84
|
|
|
84
85
|
const cacheDirectory = resolveCacheDirectory(input.cacheDirectory);
|
|
85
86
|
|
|
86
|
-
const candidates: OauthTokenCandidate[] = entries.map(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
snapshot
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
87
|
+
const candidates: OauthTokenCandidate[] = entries.map(
|
|
88
|
+
({ name, token, selectionWeight }) => {
|
|
89
|
+
const snapshot = readRateLimit(token, cacheDirectory);
|
|
90
|
+
const fableLimit = snapshot?.modelWeeklyLimits[FABLE_LIMIT_TYPE];
|
|
91
|
+
const fableRejected =
|
|
92
|
+
fableLimit !== undefined &&
|
|
93
|
+
fableLimit.rejected &&
|
|
94
|
+
input.nowEpochSeconds <= fableLimit.resetsAt;
|
|
95
|
+
return {
|
|
96
|
+
name,
|
|
97
|
+
token,
|
|
98
|
+
snapshot:
|
|
99
|
+
snapshot === null
|
|
100
|
+
? null
|
|
101
|
+
: {
|
|
102
|
+
fiveHourUtilization: snapshot.fiveHourUtilization,
|
|
103
|
+
fiveHourReset: snapshot.fiveHourReset,
|
|
104
|
+
sevenDayUtilization: snapshot.sevenDayUtilization,
|
|
105
|
+
sevenDayReset: snapshot.sevenDayReset,
|
|
106
|
+
},
|
|
107
|
+
subscriptionDisabled: snapshot?.subscriptionDisabled ?? false,
|
|
108
|
+
unifiedRejected: snapshot?.unifiedRejected ?? false,
|
|
109
|
+
fableRejected,
|
|
110
|
+
selectionWeight: selectionWeight ?? DEFAULT_SELECTION_WEIGHT,
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
);
|
|
110
114
|
|
|
111
115
|
const result = this.useCase.run(candidates, input.nowEpochSeconds);
|
|
112
116
|
|
|
@@ -50,6 +50,58 @@ describe('loadTokenEntries', () => {
|
|
|
50
50
|
fs.writeFileSync(filePath, JSON.stringify([{ name: 'no-token' }]));
|
|
51
51
|
expect(loadTokenEntries(filePath)).toBeNull();
|
|
52
52
|
});
|
|
53
|
+
|
|
54
|
+
it('should include selectionWeight when the entry has a finite number', () => {
|
|
55
|
+
const filePath = path.join(tempDir, 'tokens.json');
|
|
56
|
+
fs.writeFileSync(
|
|
57
|
+
filePath,
|
|
58
|
+
JSON.stringify([
|
|
59
|
+
{ name: 'alice', token: 'token-a', selectionWeight: 0.8 },
|
|
60
|
+
{ name: 'bob', token: 'token-b', selectionWeight: 2 },
|
|
61
|
+
]),
|
|
62
|
+
);
|
|
63
|
+
expect(loadTokenEntries(filePath)).toEqual([
|
|
64
|
+
{ name: 'alice', token: 'token-a', selectionWeight: 0.8 },
|
|
65
|
+
{ name: 'bob', token: 'token-b', selectionWeight: 2 },
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should omit selectionWeight when it is absent', () => {
|
|
70
|
+
const filePath = path.join(tempDir, 'tokens.json');
|
|
71
|
+
fs.writeFileSync(
|
|
72
|
+
filePath,
|
|
73
|
+
JSON.stringify([{ name: 'alice', token: 'token-a' }]),
|
|
74
|
+
);
|
|
75
|
+
expect(loadTokenEntries(filePath)).toEqual([
|
|
76
|
+
{ name: 'alice', token: 'token-a' },
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should ignore a non-numeric selectionWeight', () => {
|
|
81
|
+
const filePath = path.join(tempDir, 'tokens.json');
|
|
82
|
+
fs.writeFileSync(
|
|
83
|
+
filePath,
|
|
84
|
+
JSON.stringify([
|
|
85
|
+
{ name: 'alice', token: 'token-a', selectionWeight: 'high' },
|
|
86
|
+
{ name: 'bob', token: 'token-b', selectionWeight: null },
|
|
87
|
+
]),
|
|
88
|
+
);
|
|
89
|
+
expect(loadTokenEntries(filePath)).toEqual([
|
|
90
|
+
{ name: 'alice', token: 'token-a' },
|
|
91
|
+
{ name: 'bob', token: 'token-b' },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should carry selectionWeight on a positionally named entry', () => {
|
|
96
|
+
const filePath = path.join(tempDir, 'tokens.json');
|
|
97
|
+
fs.writeFileSync(
|
|
98
|
+
filePath,
|
|
99
|
+
JSON.stringify([{ token: 'token-a', selectionWeight: 0.5 }]),
|
|
100
|
+
);
|
|
101
|
+
expect(loadTokenEntries(filePath)).toEqual([
|
|
102
|
+
{ name: 'token-1', token: 'token-a', selectionWeight: 0.5 },
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
53
105
|
});
|
|
54
106
|
|
|
55
107
|
describe('TokenListLoader', () => {
|
|
@@ -18,8 +18,19 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
|
18
18
|
export type TokenEntry = {
|
|
19
19
|
name: string;
|
|
20
20
|
token: string;
|
|
21
|
+
selectionWeight?: number;
|
|
21
22
|
};
|
|
22
23
|
|
|
24
|
+
const parseSelectionWeight = (value: unknown): number | null =>
|
|
25
|
+
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
26
|
+
|
|
27
|
+
const buildTokenEntry = (
|
|
28
|
+
name: string,
|
|
29
|
+
token: string,
|
|
30
|
+
selectionWeight: number | null,
|
|
31
|
+
): TokenEntry =>
|
|
32
|
+
selectionWeight === null ? { name, token } : { name, token, selectionWeight };
|
|
33
|
+
|
|
23
34
|
export const loadTokenEntries = (jsonPath: string): TokenEntry[] | null => {
|
|
24
35
|
const resolved = expandHome(jsonPath);
|
|
25
36
|
if (!fs.existsSync(resolved)) return null;
|
|
@@ -34,12 +45,21 @@ export const loadTokenEntries = (jsonPath: string): TokenEntry[] | null => {
|
|
|
34
45
|
typeof entry.token === 'string' &&
|
|
35
46
|
typeof entry.name === 'string'
|
|
36
47
|
) {
|
|
37
|
-
entries.push(
|
|
48
|
+
entries.push(
|
|
49
|
+
buildTokenEntry(
|
|
50
|
+
entry.name,
|
|
51
|
+
entry.token,
|
|
52
|
+
parseSelectionWeight(entry.selectionWeight),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
38
55
|
} else if (isRecord(entry) && typeof entry.token === 'string') {
|
|
39
|
-
entries.push(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
entries.push(
|
|
57
|
+
buildTokenEntry(
|
|
58
|
+
`token-${entries.length + 1}`,
|
|
59
|
+
entry.token,
|
|
60
|
+
parseSelectionWeight(entry.selectionWeight),
|
|
61
|
+
),
|
|
62
|
+
);
|
|
43
63
|
}
|
|
44
64
|
}
|
|
45
65
|
return entries.length > 0 ? entries : null;
|
|
@@ -123,6 +123,61 @@ describe('NodeTmuxSessionRepository', () => {
|
|
|
123
123
|
'Failed to kill tmux session "missing_session": exit code 1',
|
|
124
124
|
);
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
it('stops the systemd user scope for the session after killing it, wrapping the stop with reset-failed', async () => {
|
|
128
|
+
const runner = createMockRunner();
|
|
129
|
+
runner.runCommand.mockResolvedValue({
|
|
130
|
+
stdout: '',
|
|
131
|
+
stderr: '',
|
|
132
|
+
exitCode: 0,
|
|
133
|
+
});
|
|
134
|
+
const repository = new NodeTmuxSessionRepository(runner);
|
|
135
|
+
|
|
136
|
+
await repository.killSession('https_//github_com/owner/repo/issues/9');
|
|
137
|
+
|
|
138
|
+
const expectedScopeUnitName =
|
|
139
|
+
'cl-https---github-com-owner-repo-issues-9.scope';
|
|
140
|
+
const calls = runner.runCommand.mock.calls;
|
|
141
|
+
expect(calls[0]).toEqual([
|
|
142
|
+
'tmux',
|
|
143
|
+
['kill-session', '-t', 'https_//github_com/owner/repo/issues/9'],
|
|
144
|
+
]);
|
|
145
|
+
const systemctlCalls = calls.filter((call) => call[0] === 'systemctl');
|
|
146
|
+
expect(systemctlCalls).toEqual([
|
|
147
|
+
['systemctl', ['--user', 'reset-failed', expectedScopeUnitName]],
|
|
148
|
+
['systemctl', ['--user', 'stop', expectedScopeUnitName]],
|
|
149
|
+
['systemctl', ['--user', 'reset-failed', expectedScopeUnitName]],
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('logs an error but does not throw when stopping the systemd user scope fails', async () => {
|
|
154
|
+
const runner = createMockRunner();
|
|
155
|
+
runner.runCommand.mockImplementation(async (program: string) => {
|
|
156
|
+
if (program === 'systemctl') {
|
|
157
|
+
return {
|
|
158
|
+
stdout: '',
|
|
159
|
+
stderr: 'Failed to stop cl-leader-session.scope: Unit not loaded.',
|
|
160
|
+
exitCode: 5,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { stdout: '', stderr: '', exitCode: 0 };
|
|
164
|
+
});
|
|
165
|
+
const errorSpy = jest
|
|
166
|
+
.spyOn(console, 'error')
|
|
167
|
+
.mockImplementation(() => undefined);
|
|
168
|
+
const repository = new NodeTmuxSessionRepository(runner);
|
|
169
|
+
|
|
170
|
+
await expect(
|
|
171
|
+
repository.killSession('leader_session'),
|
|
172
|
+
).resolves.toBeUndefined();
|
|
173
|
+
|
|
174
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
175
|
+
expect.stringContaining(
|
|
176
|
+
'Failed to stop systemd user scope "cl-leader-session.scope"',
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
errorSpy.mockRestore();
|
|
180
|
+
});
|
|
126
181
|
});
|
|
127
182
|
|
|
128
183
|
describe('listInteractiveProcessCommandLines', () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
2
2
|
import { TmuxSessionRepository } from '../../domain/usecases/adapter-interfaces/TmuxSessionRepository';
|
|
3
3
|
import { LiveTmuxSession } from '../../domain/entities/LiveTmuxSession';
|
|
4
|
+
import { clSessionScopeUnitName } from './clSessionScopeUnitName';
|
|
4
5
|
|
|
5
6
|
export class NodeTmuxSessionRepository implements TmuxSessionRepository {
|
|
6
7
|
constructor(private readonly localCommandRunner: LocalCommandRunner) {}
|
|
@@ -85,5 +86,31 @@ export class NodeTmuxSessionRepository implements TmuxSessionRepository {
|
|
|
85
86
|
}`,
|
|
86
87
|
);
|
|
87
88
|
}
|
|
89
|
+
await this.stopClSessionScope(sessionName);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
private stopClSessionScope = async (sessionName: string): Promise<void> => {
|
|
93
|
+
const scopeUnitName = clSessionScopeUnitName(sessionName);
|
|
94
|
+
await this.localCommandRunner.runCommand('systemctl', [
|
|
95
|
+
'--user',
|
|
96
|
+
'reset-failed',
|
|
97
|
+
scopeUnitName,
|
|
98
|
+
]);
|
|
99
|
+
const { stderr, exitCode } = await this.localCommandRunner.runCommand(
|
|
100
|
+
'systemctl',
|
|
101
|
+
['--user', 'stop', scopeUnitName],
|
|
102
|
+
);
|
|
103
|
+
await this.localCommandRunner.runCommand('systemctl', [
|
|
104
|
+
'--user',
|
|
105
|
+
'reset-failed',
|
|
106
|
+
scopeUnitName,
|
|
107
|
+
]);
|
|
108
|
+
if (exitCode !== 0) {
|
|
109
|
+
console.error(
|
|
110
|
+
`Failed to stop systemd user scope "${scopeUnitName}" for tmux session "${sessionName}": exit code ${exitCode}${
|
|
111
|
+
stderr ? `: ${stderr}` : ''
|
|
112
|
+
}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
88
115
|
};
|
|
89
116
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { toTmuxSessionName } from '../../domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase';
|
|
6
|
+
import { clSessionScopeUnitName } from './clSessionScopeUnitName';
|
|
7
|
+
|
|
8
|
+
const issueUrls = [
|
|
9
|
+
'https://github.com/HiromiShikata/secretary/issues/1261',
|
|
10
|
+
'https://github.com/owner/repo/issues/9',
|
|
11
|
+
'https://github.com/owner/repo/pull/42',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const sessionNames = [
|
|
15
|
+
...issueUrls,
|
|
16
|
+
...issueUrls.map((url) => toTmuxSessionName(url)),
|
|
17
|
+
'idle_no_task_session',
|
|
18
|
+
'plain-session',
|
|
19
|
+
'ALLCAPS123',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const canonicalScopeLibPath = (): string | null => {
|
|
23
|
+
const explicitPath = process.env.CL_SCOPE_LIB_PATH;
|
|
24
|
+
if (explicitPath !== undefined && existsSync(explicitPath)) {
|
|
25
|
+
return explicitPath;
|
|
26
|
+
}
|
|
27
|
+
const defaultPath = join(
|
|
28
|
+
homedir(),
|
|
29
|
+
'git',
|
|
30
|
+
'secretary',
|
|
31
|
+
'machine',
|
|
32
|
+
'sk',
|
|
33
|
+
'sh',
|
|
34
|
+
'cl-scope-lib.sh',
|
|
35
|
+
);
|
|
36
|
+
return existsSync(defaultPath) ? defaultPath : null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe('clSessionScopeUnitName cross-component contract', () => {
|
|
40
|
+
it('derives a systemd-safe scope unit name of the form cl-<safe>.scope for every session form', () => {
|
|
41
|
+
for (const sessionName of sessionNames) {
|
|
42
|
+
const unitName = clSessionScopeUnitName(sessionName);
|
|
43
|
+
expect(unitName.startsWith('cl-')).toBe(true);
|
|
44
|
+
expect(unitName.endsWith('.scope')).toBe(true);
|
|
45
|
+
const core = unitName.slice(0, -'.scope'.length);
|
|
46
|
+
expect(/^[A-Za-z0-9._-]+$/.test(core)).toBe(true);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('collapses a raw issue URL and its tmux-normalized session name to the same scope unit name', () => {
|
|
51
|
+
for (const url of issueUrls) {
|
|
52
|
+
expect(clSessionScopeUnitName(toTmuxSessionName(url))).toBe(
|
|
53
|
+
clSessionScopeUnitName(url),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const libPath = canonicalScopeLibPath();
|
|
59
|
+
const canonicalContractTest = libPath === null ? it.skip : it;
|
|
60
|
+
canonicalContractTest(
|
|
61
|
+
'matches the canonical cl_scope_unit_name derivation shared with cl',
|
|
62
|
+
() => {
|
|
63
|
+
if (libPath === null) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const sessionName of sessionNames) {
|
|
67
|
+
const canonicalUnitName = execFileSync(
|
|
68
|
+
'bash',
|
|
69
|
+
[libPath, 'unit-name', sessionName],
|
|
70
|
+
{ encoding: 'utf8' },
|
|
71
|
+
);
|
|
72
|
+
expect(clSessionScopeUnitName(sessionName)).toBe(canonicalUnitName);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
});
|
|
@@ -209,3 +209,77 @@ describe('LiveSessionOauthTokenSelectUseCase', () => {
|
|
|
209
209
|
expect(rejected?.exclusionReason).toContain('rejected');
|
|
210
210
|
});
|
|
211
211
|
});
|
|
212
|
+
|
|
213
|
+
const sweepingRandom = (count: number): (() => number) => {
|
|
214
|
+
let index = 0;
|
|
215
|
+
return () => {
|
|
216
|
+
const value = (index + 0.5) / count;
|
|
217
|
+
index += 1;
|
|
218
|
+
return value;
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const throwingRandom = (): number => {
|
|
223
|
+
throw new Error('random source must not be consulted');
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const withSelectionWeight = (
|
|
227
|
+
base: OauthTokenCandidate,
|
|
228
|
+
selectionWeight: number,
|
|
229
|
+
): OauthTokenCandidate => ({ ...base, selectionWeight });
|
|
230
|
+
|
|
231
|
+
describe('LiveSessionOauthTokenSelectUseCase selectionWeight', () => {
|
|
232
|
+
const useCase = new LiveSessionOauthTokenSelectUseCase();
|
|
233
|
+
|
|
234
|
+
it('keeps the deterministic fewest-live-sessions selection and never consults random when weights are uniform', () => {
|
|
235
|
+
const result = useCase.run(
|
|
236
|
+
[candidate('busy', snapshot({})), candidate('idle', snapshot({}))],
|
|
237
|
+
[session('busy', 'session-a')],
|
|
238
|
+
NOW,
|
|
239
|
+
throwingRandom,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(result.selected?.name).toBe('idle');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('selects a sole eligible low-weight token without consulting random (no starvation)', () => {
|
|
246
|
+
const result = useCase.run(
|
|
247
|
+
[
|
|
248
|
+
withSelectionWeight(candidate('lowWeightOnly', snapshot({})), 0.01),
|
|
249
|
+
candidate('blocked', snapshot({ fiveHourUtilization: 0.9 })),
|
|
250
|
+
],
|
|
251
|
+
[],
|
|
252
|
+
NOW,
|
|
253
|
+
throwingRandom,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(result.selected?.name).toBe('lowWeightOnly');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('chooses a lower-weight token proportionally less often among equally-idle eligible candidates', () => {
|
|
260
|
+
const count = 1000;
|
|
261
|
+
const random = sweepingRandom(count);
|
|
262
|
+
const selectionCounts = new Map<string, number>();
|
|
263
|
+
|
|
264
|
+
for (let i = 0; i < count; i += 1) {
|
|
265
|
+
const result = useCase.run(
|
|
266
|
+
[
|
|
267
|
+
withSelectionWeight(candidate('heavy', snapshot({})), 1),
|
|
268
|
+
withSelectionWeight(candidate('light', snapshot({})), 0.5),
|
|
269
|
+
],
|
|
270
|
+
[],
|
|
271
|
+
NOW,
|
|
272
|
+
random,
|
|
273
|
+
);
|
|
274
|
+
const name = result.selected?.name ?? 'none';
|
|
275
|
+
selectionCounts.set(name, (selectionCounts.get(name) ?? 0) + 1);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const heavy = selectionCounts.get('heavy') ?? 0;
|
|
279
|
+
const light = selectionCounts.get('light') ?? 0;
|
|
280
|
+
expect(heavy + light).toBe(count);
|
|
281
|
+
expect(light).toBeGreaterThan(0);
|
|
282
|
+
expect(light).toBeLessThan(heavy);
|
|
283
|
+
expect(Math.abs(light / count - 1 / 3)).toBeLessThan(0.02);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -2,6 +2,8 @@ import { ClaudeLiveSession } from './adapter-interfaces/ClaudeLiveSessionReposit
|
|
|
2
2
|
import {
|
|
3
3
|
OauthTokenCandidate,
|
|
4
4
|
OauthTokenSelectUseCase,
|
|
5
|
+
SelectionRandom,
|
|
6
|
+
selectWeightedCandidate,
|
|
5
7
|
} from './OauthTokenSelectUseCase';
|
|
6
8
|
|
|
7
9
|
export type LiveSessionOauthTokenCandidateMetrics = {
|
|
@@ -28,10 +30,12 @@ export class LiveSessionOauthTokenSelectUseCase {
|
|
|
28
30
|
candidates: OauthTokenCandidate[],
|
|
29
31
|
liveSessions: ClaudeLiveSession[],
|
|
30
32
|
nowEpochSeconds: number,
|
|
33
|
+
random: SelectionRandom = Math.random,
|
|
31
34
|
): LiveSessionOauthTokenSelectResult => {
|
|
32
35
|
const rateLimitResult = this.rateLimitSelectUseCase.run(
|
|
33
36
|
candidates,
|
|
34
37
|
nowEpochSeconds,
|
|
38
|
+
() => 0,
|
|
35
39
|
);
|
|
36
40
|
const liveSessionCountByToken = this.liveSessionCountByToken(liveSessions);
|
|
37
41
|
|
|
@@ -60,13 +64,20 @@ export class LiveSessionOauthTokenSelectUseCase {
|
|
|
60
64
|
return { selected: null, metrics };
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
const
|
|
67
|
+
const deterministicBest = eligible.reduce((bestEntry, currentEntry) =>
|
|
64
68
|
this.preferred(currentEntry.metric, bestEntry.metric)
|
|
65
69
|
? currentEntry
|
|
66
70
|
: bestEntry,
|
|
67
71
|
);
|
|
68
72
|
|
|
69
|
-
|
|
73
|
+
const selected = selectWeightedCandidate(
|
|
74
|
+
eligible,
|
|
75
|
+
(entry) => entry.candidate,
|
|
76
|
+
deterministicBest,
|
|
77
|
+
random,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return { selected: selected.candidate, metrics };
|
|
70
81
|
};
|
|
71
82
|
|
|
72
83
|
private preferred = (
|