switchroom 0.10.0 → 0.11.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.
- package/README.md +5 -4
- package/dist/agent-scheduler/index.js +2 -2
- package/dist/auth-broker/index.js +125 -3
- package/dist/cli/drive-write-pretool.mjs +5436 -0
- package/dist/cli/switchroom.js +231 -29
- package/dist/host-control/main.js +2 -2
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
- package/telegram-plugin/admin-commands/index.ts +2 -0
- package/telegram-plugin/auth-snapshot-format.ts +612 -0
- package/telegram-plugin/auto-fallback-fleet.ts +215 -0
- package/telegram-plugin/auto-fallback.ts +28 -301
- package/telegram-plugin/dist/gateway/gateway.js +4314 -2143
- package/telegram-plugin/fleet-fallback-gate.ts +105 -0
- package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
- package/telegram-plugin/gateway/approval-callback.ts +31 -3
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -0
- package/telegram-plugin/gateway/auth-command.ts +131 -10
- package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
- package/telegram-plugin/gateway/boot-card.ts +1 -1
- package/telegram-plugin/gateway/boot-probes.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
- package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
- package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
- package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
- package/telegram-plugin/gateway/gateway.ts +903 -173
- package/telegram-plugin/gateway/hostd-dispatch.ts +137 -2
- package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
- package/telegram-plugin/gateway/ipc-server.ts +69 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
- package/telegram-plugin/model-unavailable.ts +28 -12
- package/telegram-plugin/silence-poke.ts +153 -1
- package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
- package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
- package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
- package/telegram-plugin/tests/boot-probes.test.ts +16 -18
- package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
- package/telegram-plugin/tests/silence-poke.test.ts +237 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
- package/telegram-plugin/turn-flush-safety.ts +55 -1
- package/telegram-plugin/uat/SETUP.md +16 -12
- package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
- package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
- package/telegram-plugin/tests/hostd-dispatch.test.ts +0 -129
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the fleet-wide auto-fallback planner. Pure-data —
|
|
3
|
+
* no broker UDS, no Telegram bot. The injected `setActive` is a
|
|
4
|
+
* vi.fn we assert on.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
7
|
+
import { runFleetAutoFallback, pickFallbackTarget } from '../auto-fallback-fleet.js';
|
|
8
|
+
import type { QuotaResult, QuotaUtilization } from '../quota-check.js';
|
|
9
|
+
import type { ListStateData } from '../../src/auth/broker/client.js';
|
|
10
|
+
|
|
11
|
+
const NOW = new Date('2026-05-15T00:53:00Z');
|
|
12
|
+
|
|
13
|
+
function quota(part: Partial<QuotaUtilization>): QuotaUtilization {
|
|
14
|
+
return {
|
|
15
|
+
fiveHourUtilizationPct: 0,
|
|
16
|
+
sevenDayUtilizationPct: 0,
|
|
17
|
+
fiveHourResetAt: null,
|
|
18
|
+
sevenDayResetAt: null,
|
|
19
|
+
representativeClaim: null,
|
|
20
|
+
overageStatus: null,
|
|
21
|
+
overageDisabledReason: null,
|
|
22
|
+
...part,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function qOk(part: Partial<QuotaUtilization>): QuotaResult {
|
|
27
|
+
return { ok: true, data: quota(part) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function state(active: string, accounts: string[]): ListStateData {
|
|
31
|
+
return {
|
|
32
|
+
active,
|
|
33
|
+
fallback_order: accounts,
|
|
34
|
+
accounts: accounts.map((label) => ({ label, exhausted: false })),
|
|
35
|
+
agents: [],
|
|
36
|
+
consumers: [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('runFleetAutoFallback', () => {
|
|
41
|
+
it('switches to the lowest-utilization healthy account via broker.setActive', async () => {
|
|
42
|
+
const setActive = vi.fn(async (label: string) => ({
|
|
43
|
+
active: label,
|
|
44
|
+
fanned: ['alice', 'bob'],
|
|
45
|
+
}));
|
|
46
|
+
const out = await runFleetAutoFallback({
|
|
47
|
+
state: state('ken@x', ['ken@x', 'me@x', 'pixsoul@x']),
|
|
48
|
+
quotas: [
|
|
49
|
+
// ken: just blew 5h
|
|
50
|
+
qOk({
|
|
51
|
+
fiveHourUtilizationPct: 100,
|
|
52
|
+
fiveHourResetAt: new Date('2026-05-15T05:50:00Z'),
|
|
53
|
+
representativeClaim: 'five_hour',
|
|
54
|
+
}),
|
|
55
|
+
// me: dead on 7d for 2 days
|
|
56
|
+
qOk({
|
|
57
|
+
sevenDayUtilizationPct: 100,
|
|
58
|
+
sevenDayResetAt: new Date('2026-05-17T10:00:00Z'),
|
|
59
|
+
representativeClaim: 'seven_day',
|
|
60
|
+
}),
|
|
61
|
+
// pixsoul: healthy 5h/7d
|
|
62
|
+
qOk({ fiveHourUtilizationPct: 8, sevenDayUtilizationPct: 20 }),
|
|
63
|
+
],
|
|
64
|
+
setActive,
|
|
65
|
+
triggerAgent: 'carrie',
|
|
66
|
+
now: NOW,
|
|
67
|
+
tz: 'UTC',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(out.kind).toBe('switched');
|
|
71
|
+
expect(setActive).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(setActive).toHaveBeenCalledWith('pixsoul@x');
|
|
73
|
+
if (out.kind === 'switched') {
|
|
74
|
+
expect(out.oldLabel).toBe('ken@x');
|
|
75
|
+
expect(out.newLabel).toBe('pixsoul@x');
|
|
76
|
+
expect(out.announcement).toContain('5-hour limit on ken@x');
|
|
77
|
+
expect(out.announcement).toContain('Triggered by: agent <b>carrie</b>');
|
|
78
|
+
expect(out.announcement).toContain('plenty of headroom');
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns all-blocked WITHOUT calling setActive when every alternative is blocked', async () => {
|
|
83
|
+
const setActive = vi.fn();
|
|
84
|
+
const out = await runFleetAutoFallback({
|
|
85
|
+
state: state('ken@x', ['ken@x', 'me@x']),
|
|
86
|
+
quotas: [
|
|
87
|
+
qOk({
|
|
88
|
+
fiveHourUtilizationPct: 100,
|
|
89
|
+
fiveHourResetAt: new Date('2026-05-15T05:50:00Z'),
|
|
90
|
+
representativeClaim: 'five_hour',
|
|
91
|
+
}),
|
|
92
|
+
qOk({
|
|
93
|
+
sevenDayUtilizationPct: 100,
|
|
94
|
+
sevenDayResetAt: new Date('2026-05-17T10:00:00Z'),
|
|
95
|
+
representativeClaim: 'seven_day',
|
|
96
|
+
}),
|
|
97
|
+
],
|
|
98
|
+
setActive,
|
|
99
|
+
triggerAgent: 'carrie',
|
|
100
|
+
now: NOW,
|
|
101
|
+
tz: 'UTC',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(out.kind).toBe('all-blocked');
|
|
105
|
+
expect(setActive).not.toHaveBeenCalled();
|
|
106
|
+
if (out.kind === 'all-blocked') {
|
|
107
|
+
expect(out.announcement).toContain('All accounts blocked');
|
|
108
|
+
expect(out.announcement).toContain('/auth add');
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('idempotency: skips swap when active probes healthy (stale event)', async () => {
|
|
113
|
+
const setActive = vi.fn();
|
|
114
|
+
const out = await runFleetAutoFallback({
|
|
115
|
+
state: state('ken@x', ['ken@x', 'pixsoul@x']),
|
|
116
|
+
quotas: [
|
|
117
|
+
qOk({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 }),
|
|
118
|
+
qOk({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 }),
|
|
119
|
+
],
|
|
120
|
+
setActive,
|
|
121
|
+
triggerAgent: 'carrie',
|
|
122
|
+
now: NOW,
|
|
123
|
+
tz: 'UTC',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(out.kind).toBe('no-eligible-target');
|
|
127
|
+
expect(setActive).not.toHaveBeenCalled();
|
|
128
|
+
expect(out.announcement).toContain('skipped');
|
|
129
|
+
expect(out.announcement).toContain('Stale event?');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('returns no-old-active when broker has no active account (corrupt state)', async () => {
|
|
133
|
+
const setActive = vi.fn();
|
|
134
|
+
const out = await runFleetAutoFallback({
|
|
135
|
+
state: { active: '', fallback_order: [], accounts: [], agents: [], consumers: [] },
|
|
136
|
+
quotas: [],
|
|
137
|
+
setActive,
|
|
138
|
+
triggerAgent: 'carrie',
|
|
139
|
+
now: NOW,
|
|
140
|
+
tz: 'UTC',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(out.kind).toBe('no-old-active');
|
|
144
|
+
expect(setActive).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('falls back to a throttling alternative when no healthy one exists', async () => {
|
|
148
|
+
const setActive = vi.fn(async (label: string) => ({ active: label, fanned: [] }));
|
|
149
|
+
const out = await runFleetAutoFallback({
|
|
150
|
+
state: state('ken@x', ['ken@x', 'pixsoul@x']),
|
|
151
|
+
quotas: [
|
|
152
|
+
qOk({
|
|
153
|
+
fiveHourUtilizationPct: 100,
|
|
154
|
+
fiveHourResetAt: new Date('2026-05-15T05:50:00Z'),
|
|
155
|
+
representativeClaim: 'five_hour',
|
|
156
|
+
}),
|
|
157
|
+
// pixsoul throttling at 85% but not blocked
|
|
158
|
+
qOk({ fiveHourUtilizationPct: 85, sevenDayUtilizationPct: 20 }),
|
|
159
|
+
],
|
|
160
|
+
setActive,
|
|
161
|
+
triggerAgent: 'carrie',
|
|
162
|
+
now: NOW,
|
|
163
|
+
tz: 'UTC',
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(out.kind).toBe('switched');
|
|
167
|
+
expect(setActive).toHaveBeenCalledWith('pixsoul@x');
|
|
168
|
+
if (out.kind === 'switched') {
|
|
169
|
+
expect(out.announcement).toContain('near limit — watch this');
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('skips unknown-health (probe failed) when picking a target', async () => {
|
|
174
|
+
const setActive = vi.fn(async (label: string) => ({ active: label, fanned: [] }));
|
|
175
|
+
const out = await runFleetAutoFallback({
|
|
176
|
+
state: state('ken@x', ['ken@x', 'broken@x', 'pixsoul@x']),
|
|
177
|
+
quotas: [
|
|
178
|
+
qOk({ fiveHourUtilizationPct: 100, fiveHourResetAt: new Date('2026-05-15T05:50:00Z') }),
|
|
179
|
+
{ ok: false, reason: 'HTTP 401' },
|
|
180
|
+
qOk({ fiveHourUtilizationPct: 5 }),
|
|
181
|
+
],
|
|
182
|
+
setActive,
|
|
183
|
+
triggerAgent: 'carrie',
|
|
184
|
+
now: NOW,
|
|
185
|
+
tz: 'UTC',
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(out.kind).toBe('switched');
|
|
189
|
+
expect(setActive).toHaveBeenCalledWith('pixsoul@x');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('pickFallbackTarget', () => {
|
|
194
|
+
it('prefers lower-5h-utilization healthy account', () => {
|
|
195
|
+
const snaps = [
|
|
196
|
+
{ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100 }) },
|
|
197
|
+
{ label: 'low@x', isActive: false, quota: quota({ fiveHourUtilizationPct: 5 }) },
|
|
198
|
+
{ label: 'med@x', isActive: false, quota: quota({ fiveHourUtilizationPct: 30 }) },
|
|
199
|
+
];
|
|
200
|
+
const target = pickFallbackTarget(snaps);
|
|
201
|
+
expect(target?.label).toBe('low@x');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('returns null when only blocked alternatives exist', () => {
|
|
205
|
+
const snaps = [
|
|
206
|
+
{ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100 }) },
|
|
207
|
+
{ label: 'b@x', isActive: false, quota: quota({ sevenDayUtilizationPct: 100 }) },
|
|
208
|
+
];
|
|
209
|
+
expect(pickFallbackTarget(snaps)).toBeNull();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -1,381 +1,83 @@
|
|
|
1
|
-
import { describe,
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
function okQuota(fivePct: number, sevenPct = 0, resetOffsetMs = 60 * 60_000): QuotaResult {
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { loadLockout, type LockoutRecord, type LockoutPersistOps } from "../auto-fallback.js";
|
|
3
|
+
|
|
4
|
+
// The auto-fallback module is read-only since PR #1329 — the writer +
|
|
5
|
+
// decision logic + plan executor were retired alongside the legacy
|
|
6
|
+
// per-agent poller (fleet-wide path supersedes them). The only
|
|
7
|
+
// remaining consumer is gateway.ts's `isAutoFallbackCooldownActive`,
|
|
8
|
+
// which reads the existing on-disk lockout to bound a pending-restart
|
|
9
|
+
// drain. This test set covers that one read path.
|
|
10
|
+
|
|
11
|
+
const EMPTY: LockoutRecord = { lastTransitionedFrom: null, lastTransitionAt: 0 };
|
|
12
|
+
|
|
13
|
+
function fakeOps(initial: Record<string, string> = {}): LockoutPersistOps & {
|
|
14
|
+
files: Map<string, string>;
|
|
15
|
+
} {
|
|
16
|
+
const files = new Map(Object.entries(initial));
|
|
19
17
|
return {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
sevenDayResetAt: new Date(NOW + resetOffsetMs * 2),
|
|
26
|
-
representativeClaim: null,
|
|
27
|
-
overageStatus: null,
|
|
28
|
-
overageDisabledReason: null,
|
|
18
|
+
files,
|
|
19
|
+
readFileSync: (p: string) => {
|
|
20
|
+
const v = files.get(p);
|
|
21
|
+
if (v === undefined) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
22
|
+
return v;
|
|
29
23
|
},
|
|
24
|
+
// unused by loadLockout — present to satisfy the interface.
|
|
25
|
+
writeFileSync: () => { throw new Error("writes are retired"); },
|
|
26
|
+
mkdirSync: () => { throw new Error("mkdir is retired"); },
|
|
27
|
+
existsSync: (p: string) => files.has(p),
|
|
28
|
+
joinPath: (...parts: string[]) => parts.join("/"),
|
|
30
29
|
};
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
describe("
|
|
34
|
-
it("
|
|
35
|
-
|
|
36
|
-
quota: okQuota(100),
|
|
37
|
-
activeSlot: null,
|
|
38
|
-
now: NOW,
|
|
39
|
-
lockout: emptyLockout(),
|
|
40
|
-
});
|
|
41
|
-
expect(d.action).toBe("noop");
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("utilization below threshold → noop", () => {
|
|
45
|
-
const d = evaluateFallbackTrigger({
|
|
46
|
-
quota: okQuota(95),
|
|
47
|
-
activeSlot: "default",
|
|
48
|
-
now: NOW,
|
|
49
|
-
lockout: emptyLockout(),
|
|
50
|
-
});
|
|
51
|
-
expect(d.action).toBe("noop");
|
|
32
|
+
describe("loadLockout — read-only after #1329", () => {
|
|
33
|
+
it("returns the empty lockout when no file exists", () => {
|
|
34
|
+
expect(loadLockout("/agent", fakeOps())).toEqual(EMPTY);
|
|
52
35
|
});
|
|
53
36
|
|
|
54
|
-
it("
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
37
|
+
it("returns the parsed record on a well-formed file", () => {
|
|
38
|
+
const ops = fakeOps({
|
|
39
|
+
"/agent/.claude/auto-fallback-lockout.json": JSON.stringify({
|
|
40
|
+
lastTransitionedFrom: "ken@example.com",
|
|
41
|
+
lastTransitionAt: 1_700_000_000_000,
|
|
42
|
+
}),
|
|
60
43
|
});
|
|
61
|
-
expect(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
expect(d.utilizationPct).toBe(DEFAULT_TRIGGER_UTILIZATION_PCT);
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("7d utilization over threshold (5h fine) → still fallback", () => {
|
|
69
|
-
const d = evaluateFallbackTrigger({
|
|
70
|
-
quota: okQuota(80, 100),
|
|
71
|
-
activeSlot: "default",
|
|
72
|
-
now: NOW,
|
|
73
|
-
lockout: emptyLockout(),
|
|
44
|
+
expect(loadLockout("/agent", ops)).toEqual({
|
|
45
|
+
lastTransitionedFrom: "ken@example.com",
|
|
46
|
+
lastTransitionAt: 1_700_000_000_000,
|
|
74
47
|
});
|
|
75
|
-
expect(d.action).toBe("fallback");
|
|
76
|
-
if (d.action === "fallback") expect(d.utilizationPct).toBe(100);
|
|
77
48
|
});
|
|
78
49
|
|
|
79
|
-
it("
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
activeSlot: "default",
|
|
83
|
-
now: NOW,
|
|
84
|
-
lockout: emptyLockout(),
|
|
85
|
-
saw429: true,
|
|
50
|
+
it("falls back to the empty lockout on malformed JSON", () => {
|
|
51
|
+
const ops = fakeOps({
|
|
52
|
+
"/agent/.claude/auto-fallback-lockout.json": "{broken json",
|
|
86
53
|
});
|
|
87
|
-
expect(
|
|
88
|
-
if (d.action === "fallback") expect(d.triggerReason).toBe("429-response");
|
|
54
|
+
expect(loadLockout("/agent", ops)).toEqual(EMPTY);
|
|
89
55
|
});
|
|
90
56
|
|
|
91
|
-
it("
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
quota: okQuota(100),
|
|
95
|
-
activeSlot: "default",
|
|
96
|
-
now: NOW,
|
|
97
|
-
lockout,
|
|
57
|
+
it("falls back to the empty lockout on missing fields", () => {
|
|
58
|
+
const ops = fakeOps({
|
|
59
|
+
"/agent/.claude/auto-fallback-lockout.json": JSON.stringify({ wrong: "shape" }),
|
|
98
60
|
});
|
|
99
|
-
expect(
|
|
100
|
-
if (d.action === "noop") expect(d.reason).toMatch(/cooldown/);
|
|
61
|
+
expect(loadLockout("/agent", ops)).toEqual(EMPTY);
|
|
101
62
|
});
|
|
102
63
|
|
|
103
|
-
it("
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
quota: okQuota(100),
|
|
110
|
-
activeSlot: "default",
|
|
111
|
-
now: NOW,
|
|
112
|
-
lockout,
|
|
64
|
+
it("falls back to the empty lockout when lastTransitionAt is non-finite", () => {
|
|
65
|
+
const ops = fakeOps({
|
|
66
|
+
"/agent/.claude/auto-fallback-lockout.json": JSON.stringify({
|
|
67
|
+
lastTransitionedFrom: "ken@example.com",
|
|
68
|
+
lastTransitionAt: "not a number",
|
|
69
|
+
}),
|
|
113
70
|
});
|
|
114
|
-
expect(
|
|
71
|
+
expect(loadLockout("/agent", ops)).toEqual(EMPTY);
|
|
115
72
|
});
|
|
116
73
|
|
|
117
|
-
it("
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
lockout,
|
|
74
|
+
it("accepts an explicit-null lastTransitionedFrom", () => {
|
|
75
|
+
const ops = fakeOps({
|
|
76
|
+
"/agent/.claude/auto-fallback-lockout.json": JSON.stringify({
|
|
77
|
+
lastTransitionedFrom: null,
|
|
78
|
+
lastTransitionAt: 0,
|
|
79
|
+
}),
|
|
124
80
|
});
|
|
125
|
-
expect(
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("quota fetch failed → noop with reason", () => {
|
|
129
|
-
const quota: QuotaResult = { ok: false, reason: "no OAuth token" };
|
|
130
|
-
const d = evaluateFallbackTrigger({
|
|
131
|
-
quota,
|
|
132
|
-
activeSlot: "default",
|
|
133
|
-
now: NOW,
|
|
134
|
-
lockout: emptyLockout(),
|
|
135
|
-
});
|
|
136
|
-
expect(d.action).toBe("noop");
|
|
137
|
-
if (d.action === "noop") expect(d.reason).toMatch(/no OAuth/);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("quota ok but no utilization headers → noop", () => {
|
|
141
|
-
const quota: QuotaResult = {
|
|
142
|
-
ok: true,
|
|
143
|
-
data: {
|
|
144
|
-
fiveHourUtilizationPct: null as unknown as number,
|
|
145
|
-
sevenDayUtilizationPct: null as unknown as number,
|
|
146
|
-
fiveHourResetAt: null,
|
|
147
|
-
sevenDayResetAt: null,
|
|
148
|
-
representativeClaim: null,
|
|
149
|
-
overageStatus: null,
|
|
150
|
-
overageDisabledReason: null,
|
|
151
|
-
},
|
|
152
|
-
};
|
|
153
|
-
const d = evaluateFallbackTrigger({
|
|
154
|
-
quota,
|
|
155
|
-
activeSlot: "default",
|
|
156
|
-
now: NOW,
|
|
157
|
-
lockout: emptyLockout(),
|
|
158
|
-
});
|
|
159
|
-
expect(d.action).toBe("noop");
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("custom threshold override respected", () => {
|
|
163
|
-
const d = evaluateFallbackTrigger({
|
|
164
|
-
quota: okQuota(90),
|
|
165
|
-
activeSlot: "default",
|
|
166
|
-
now: NOW,
|
|
167
|
-
lockout: emptyLockout(),
|
|
168
|
-
thresholdPct: 85,
|
|
169
|
-
});
|
|
170
|
-
expect(d.action).toBe("fallback");
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
describe("performAutoFallback", () => {
|
|
175
|
-
const baseDecision: Extract<FallbackDecision, { action: "fallback" }> = {
|
|
176
|
-
action: "fallback",
|
|
177
|
-
triggerReason: "utilization-over-threshold",
|
|
178
|
-
resetAtMs: NOW + 60 * 60_000,
|
|
179
|
-
utilizationPct: 100,
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
function mkDeps(overrides?: Partial<{ active: string | null; next: string | null; previous: string | null }>) {
|
|
183
|
-
const marks: Array<{ slot: string; resetAtMs?: number; reason?: string }> = [];
|
|
184
|
-
const fallbacks: Array<{ name: string; agentDir: string }> = [];
|
|
185
|
-
const initialActive = overrides?.active === undefined ? "default" : overrides.active;
|
|
186
|
-
return {
|
|
187
|
-
marks,
|
|
188
|
-
fallbacks,
|
|
189
|
-
deps: {
|
|
190
|
-
currentActiveSlot: () => initialActive,
|
|
191
|
-
markSlotQuotaExhausted: (
|
|
192
|
-
_agentDir: string,
|
|
193
|
-
slot: string,
|
|
194
|
-
resetAtMs?: number,
|
|
195
|
-
reason?: string,
|
|
196
|
-
) => {
|
|
197
|
-
marks.push({ slot, resetAtMs, reason });
|
|
198
|
-
},
|
|
199
|
-
fallbackToNextSlot: (name: string, agentDir: string) => {
|
|
200
|
-
fallbacks.push({ name, agentDir });
|
|
201
|
-
return {
|
|
202
|
-
newActive: overrides?.next === undefined ? "personal" : overrides.next,
|
|
203
|
-
previous: overrides?.previous === undefined ? initialActive : overrides.previous,
|
|
204
|
-
};
|
|
205
|
-
},
|
|
206
|
-
},
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
it("healthy fallback available → executes swap", () => {
|
|
211
|
-
const { marks, fallbacks, deps } = mkDeps();
|
|
212
|
-
const plan = performAutoFallback({
|
|
213
|
-
agentDir: "/tmp/x",
|
|
214
|
-
agentName: "clerk",
|
|
215
|
-
decision: baseDecision,
|
|
216
|
-
deps,
|
|
217
|
-
});
|
|
218
|
-
expect(plan.kind).toBe("executed");
|
|
219
|
-
if (plan.kind === "executed") {
|
|
220
|
-
expect(plan.previousSlot).toBe("default");
|
|
221
|
-
expect(plan.newSlot).toBe("personal");
|
|
222
|
-
expect(plan.notificationHtml).toContain("Quota exhausted");
|
|
223
|
-
// Slot names appear in the detail text (migrated to renderOperatorEvent)
|
|
224
|
-
expect(plan.notificationHtml).toContain("default");
|
|
225
|
-
expect(plan.notificationHtml).toContain("personal");
|
|
226
|
-
}
|
|
227
|
-
expect(marks).toHaveLength(1);
|
|
228
|
-
expect(marks[0].slot).toBe("default");
|
|
229
|
-
expect(marks[0].reason).toBe("utilization-over-threshold");
|
|
230
|
-
expect(fallbacks).toHaveLength(1);
|
|
231
|
-
expect(fallbacks[0].name).toBe("clerk");
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it("no fallback available → exhausted-all plan", () => {
|
|
235
|
-
const { marks, deps } = mkDeps({ next: null });
|
|
236
|
-
const plan = performAutoFallback({
|
|
237
|
-
agentDir: "/tmp/x",
|
|
238
|
-
agentName: "clerk",
|
|
239
|
-
decision: baseDecision,
|
|
240
|
-
deps,
|
|
241
|
-
});
|
|
242
|
-
expect(plan.kind).toBe("exhausted-all");
|
|
243
|
-
if (plan.kind === "exhausted-all") {
|
|
244
|
-
expect(plan.notificationHtml).toContain("All account slots");
|
|
245
|
-
expect(plan.notificationHtml).toContain("/auth add clerk");
|
|
246
|
-
}
|
|
247
|
-
// Still marks the active slot exhausted before giving up.
|
|
248
|
-
expect(marks).toHaveLength(1);
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it("fallbackToNextSlot returns same slot → treated as exhausted-all", () => {
|
|
252
|
-
const { deps } = mkDeps({ next: "default", previous: "default" });
|
|
253
|
-
const plan = performAutoFallback({
|
|
254
|
-
agentDir: "/tmp/x",
|
|
255
|
-
agentName: "clerk",
|
|
256
|
-
decision: baseDecision,
|
|
257
|
-
deps,
|
|
258
|
-
});
|
|
259
|
-
expect(plan.kind).toBe("exhausted-all");
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it("no active slot when invoked → exhausted-all without marking", () => {
|
|
263
|
-
const { marks, deps } = mkDeps({ active: null });
|
|
264
|
-
const plan = performAutoFallback({
|
|
265
|
-
agentDir: "/tmp/x",
|
|
266
|
-
agentName: "clerk",
|
|
267
|
-
decision: baseDecision,
|
|
268
|
-
deps,
|
|
269
|
-
});
|
|
270
|
-
expect(plan.kind).toBe("exhausted-all");
|
|
271
|
-
expect(marks).toHaveLength(0);
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it("uses Anthropic reset timestamp when available", () => {
|
|
275
|
-
const { deps } = mkDeps();
|
|
276
|
-
const resetAt = NOW + 3600_000;
|
|
277
|
-
const plan = performAutoFallback({
|
|
278
|
-
agentDir: "/tmp/x",
|
|
279
|
-
agentName: "clerk",
|
|
280
|
-
decision: { ...baseDecision, resetAtMs: resetAt },
|
|
281
|
-
deps,
|
|
282
|
-
});
|
|
283
|
-
if (plan.kind === "executed") {
|
|
284
|
-
expect(plan.resetAtMs).toBe(resetAt);
|
|
285
|
-
expect(plan.notificationHtml).toContain("Reset at");
|
|
286
|
-
}
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it("escapes HTML in agent + slot names in notification", () => {
|
|
290
|
-
const { deps } = mkDeps({ previous: "<evil>", next: "<also>" });
|
|
291
|
-
const plan = performAutoFallback({
|
|
292
|
-
agentDir: "/tmp/x",
|
|
293
|
-
agentName: "<danger>",
|
|
294
|
-
decision: baseDecision,
|
|
295
|
-
deps,
|
|
296
|
-
});
|
|
297
|
-
expect(plan.notificationHtml).toContain("<evil>");
|
|
298
|
-
expect(plan.notificationHtml).toContain("<also>");
|
|
299
|
-
expect(plan.notificationHtml).toContain("<danger>");
|
|
300
|
-
expect(plan.notificationHtml).not.toContain("<evil>");
|
|
301
|
-
});
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
describe("nextLockout / emptyLockout", () => {
|
|
305
|
-
it("emptyLockout has no previous slot", () => {
|
|
306
|
-
const l = emptyLockout();
|
|
307
|
-
expect(l.lastTransitionedFrom).toBeNull();
|
|
308
|
-
expect(l.lastTransitionAt).toBe(0);
|
|
309
|
-
});
|
|
310
|
-
it("nextLockout records the slot we just transitioned from", () => {
|
|
311
|
-
const l = nextLockout("default", NOW);
|
|
312
|
-
expect(l.lastTransitionedFrom).toBe("default");
|
|
313
|
-
expect(l.lastTransitionAt).toBe(NOW);
|
|
314
|
-
});
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
describe("loadLockout / saveLockout (#417)", () => {
|
|
318
|
-
function fakeOps() {
|
|
319
|
-
const files = new Map<string, string>();
|
|
320
|
-
const dirs = new Set<string>();
|
|
321
|
-
return {
|
|
322
|
-
files,
|
|
323
|
-
dirs,
|
|
324
|
-
readFileSync: (p: string) => {
|
|
325
|
-
const v = files.get(p);
|
|
326
|
-
if (v === undefined) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
327
|
-
return v;
|
|
328
|
-
},
|
|
329
|
-
writeFileSync: (p: string, data: string) => {
|
|
330
|
-
files.set(p, data);
|
|
331
|
-
},
|
|
332
|
-
existsSync: (p: string) => files.has(p),
|
|
333
|
-
mkdirSync: (p: string) => {
|
|
334
|
-
dirs.add(p);
|
|
335
|
-
},
|
|
336
|
-
joinPath: (...parts: string[]) => parts.join("/"),
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
it("returns emptyLockout when no file exists", () => {
|
|
341
|
-
const ops = fakeOps();
|
|
342
|
-
expect(loadLockout("/agent", ops)).toEqual(emptyLockout());
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
it("round-trips a saved lockout", () => {
|
|
346
|
-
const ops = fakeOps();
|
|
347
|
-
const original = nextLockout("default", NOW);
|
|
348
|
-
saveLockout("/agent", original, ops);
|
|
349
|
-
expect(loadLockout("/agent", ops)).toEqual(original);
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it("creates the .claude directory before writing", () => {
|
|
353
|
-
const ops = fakeOps();
|
|
354
|
-
saveLockout("/agent", nextLockout("default", NOW), ops);
|
|
355
|
-
expect(ops.dirs.has("/agent/.claude")).toBe(true);
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it("falls back to emptyLockout on malformed JSON (not a hard fail)", () => {
|
|
359
|
-
const ops = fakeOps();
|
|
360
|
-
ops.files.set("/agent/.claude/auto-fallback-lockout.json", "{broken json");
|
|
361
|
-
expect(loadLockout("/agent", ops)).toEqual(emptyLockout());
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
it("falls back to emptyLockout on missing fields", () => {
|
|
365
|
-
const ops = fakeOps();
|
|
366
|
-
ops.files.set(
|
|
367
|
-
"/agent/.claude/auto-fallback-lockout.json",
|
|
368
|
-
JSON.stringify({ wrong: "shape" }),
|
|
369
|
-
);
|
|
370
|
-
expect(loadLockout("/agent", ops)).toEqual(emptyLockout());
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
it("falls back to emptyLockout on non-finite lastTransitionAt", () => {
|
|
374
|
-
const ops = fakeOps();
|
|
375
|
-
ops.files.set(
|
|
376
|
-
"/agent/.claude/auto-fallback-lockout.json",
|
|
377
|
-
JSON.stringify({ lastTransitionedFrom: "x", lastTransitionAt: "nope" }),
|
|
378
|
-
);
|
|
379
|
-
expect(loadLockout("/agent", ops)).toEqual(emptyLockout());
|
|
81
|
+
expect(loadLockout("/agent", ops)).toEqual(EMPTY);
|
|
380
82
|
});
|
|
381
83
|
});
|