switchroom 0.20.10 → 0.20.12
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/dist/agent-scheduler/index.js +68 -3
- package/dist/auth-broker/index.js +211 -46
- package/dist/cli/notion-write-pretool.mjs +68 -3
- package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
- package/dist/cli/self-improve-stop.mjs +889 -7
- package/dist/cli/skill-validate-pretool.mjs +82 -3
- package/dist/cli/switchroom.js +5063 -2961
- package/dist/host-control/main.js +93 -27
- package/dist/vault/approvals/kernel-server.js +92 -26
- package/dist/vault/broker/server.js +92 -26
- package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
- package/package.json +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +15 -22
- package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
- package/profiles/_shared/dev-protocol.md.hbs +1 -1
- package/profiles/_shared/execution-discipline.md.hbs +4 -4
- package/profiles/_shared/vault-protocol.md.hbs +2 -18
- package/profiles/default/CLAUDE.md.hbs +3 -5
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/skills/switchroom-cli/SKILL.md +0 -1
- package/telegram-plugin/README.md +2 -11
- package/telegram-plugin/auto-fallback-fleet.ts +37 -2
- package/telegram-plugin/bridge/bridge.ts +0 -12
- package/telegram-plugin/chat-lock.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +0 -12
- package/telegram-plugin/dist/gateway/gateway.js +1454 -976
- package/telegram-plugin/dist/server.js +0 -12
- package/telegram-plugin/fallback-card-collapse.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +11 -1
- package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
- package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
- package/telegram-plugin/gateway/gateway.ts +109 -149
- package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
- package/telegram-plugin/gateway/ipc-server.ts +28 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
- package/telegram-plugin/gateway/narrative-lane.ts +33 -2
- package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
- package/telegram-plugin/gateway/privacy-reset.ts +87 -0
- package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
- package/telegram-plugin/gateway/privacy-state.ts +206 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
- package/telegram-plugin/gateway/stale-pin-sweep.ts +52 -35
- package/telegram-plugin/gateway/status-pin-store.ts +10 -9
- package/telegram-plugin/gateway/stream-render.ts +4 -4
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
- package/telegram-plugin/gateway/turn-record-status.ts +32 -1
- package/telegram-plugin/hooks/hooks.json +13 -12
- package/telegram-plugin/hooks/narration-classify.mjs +1 -2
- package/telegram-plugin/hooks/silent-end-scan.mjs +1 -1
- package/telegram-plugin/slot-banner-driver.ts +42 -5
- package/telegram-plugin/status-pin.ts +2 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
- package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
- package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
- package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
- package/telegram-plugin/throttle-tier.ts +12 -3
- package/telegram-plugin/turn-flush-safety.ts +97 -0
- package/telegram-plugin/worker-activity-feed.ts +1 -1
- package/vendor/hindsight-memory/scripts/recall.py +140 -0
- package/vendor/hindsight-memory/scripts/retain.py +306 -0
- package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
- package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
- package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
|
@@ -24962,18 +24962,6 @@ var init_bridge = __esm(async () => {
|
|
|
24962
24962
|
required: ["chat_id"]
|
|
24963
24963
|
}
|
|
24964
24964
|
},
|
|
24965
|
-
{
|
|
24966
|
-
name: "pin_message",
|
|
24967
|
-
description: "Pin a message in a Telegram chat. Useful for important outputs the user wants to find later. Requires admin rights in groups.",
|
|
24968
|
-
inputSchema: {
|
|
24969
|
-
type: "object",
|
|
24970
|
-
properties: {
|
|
24971
|
-
chat_id: { type: "string" },
|
|
24972
|
-
message_id: { type: "string" }
|
|
24973
|
-
},
|
|
24974
|
-
required: ["chat_id", "message_id"]
|
|
24975
|
-
}
|
|
24976
|
-
},
|
|
24977
24965
|
{
|
|
24978
24966
|
name: "delete_message",
|
|
24979
24967
|
description: "Delete a message the bot previously sent. Prefer edit_message if you just want to update text \u2014 delete_message is for true removal.",
|
|
@@ -328,7 +328,15 @@ export interface AuthBrokerClient {
|
|
|
328
328
|
* `setActive` this needs no admin — the account is derived from the caller's
|
|
329
329
|
* identity — so auto-fallback works from any agent.
|
|
330
330
|
*/
|
|
331
|
-
markExhausted(until?: number): Promise<{
|
|
331
|
+
markExhausted(until?: number): Promise<{
|
|
332
|
+
account: string
|
|
333
|
+
rolled: string[]
|
|
334
|
+
rolledTo?: string | null
|
|
335
|
+
/** True when the caller is a strict-pinned agent (`auth.strict`): its
|
|
336
|
+
* null `rolledTo` means "riding out the wall", not fleet all-blocked.
|
|
337
|
+
* Absent on pre-flag brokers. */
|
|
338
|
+
caller_pinned_strict?: boolean
|
|
339
|
+
}>
|
|
332
340
|
/**
|
|
333
341
|
* 429 throttle tier (broker `mark-throttled`). Records a transient
|
|
334
342
|
* per-account rate limit on the CALLER's own account — `throttled_until`
|
|
@@ -344,6 +352,8 @@ export interface AuthBrokerClient {
|
|
|
344
352
|
throttled_until: number
|
|
345
353
|
escalated: boolean
|
|
346
354
|
rolledTo?: string | null
|
|
355
|
+
/** See markExhausted.caller_pinned_strict. */
|
|
356
|
+
caller_pinned_strict?: boolean
|
|
347
357
|
}>
|
|
348
358
|
rmAccount(label: string): Promise<{ label: string }>
|
|
349
359
|
refreshAccount(label: string): Promise<{ account: string; expiresAt?: number }>
|
|
@@ -70,6 +70,11 @@ import {
|
|
|
70
70
|
getProposal as getSkillProposal,
|
|
71
71
|
setProposalStatus as setSkillProposalStatus,
|
|
72
72
|
} from '../../src/self-improve/skill-proposals.js'
|
|
73
|
+
import { parseEvalCaseProposalCallback } from './eval-case-proposal-card.js'
|
|
74
|
+
import {
|
|
75
|
+
getEvalCaseProposal,
|
|
76
|
+
setEvalCaseProposalStatus,
|
|
77
|
+
} from '../../src/self-improve/eval-case-proposals.js'
|
|
73
78
|
import { maskToken } from '../secret-detect/mask.js'
|
|
74
79
|
import {
|
|
75
80
|
defaultVaultWrite,
|
|
@@ -1328,6 +1333,100 @@ async function handleSkillProposalCallback(ctx: Context, data: string): Promise<
|
|
|
1328
1333
|
)
|
|
1329
1334
|
}
|
|
1330
1335
|
|
|
1336
|
+
/**
|
|
1337
|
+
* Eval-case proposal callback (RFC amendment §"corrections as eval cases").
|
|
1338
|
+
*
|
|
1339
|
+
* On Approve this runs the DETERMINISTIC `apply-eval-case` applier via
|
|
1340
|
+
* execFileSync — NOT a model turn — so the case lands byte-exact as approved
|
|
1341
|
+
* (precedent: the `switchroom vault set` on-tap execFileSync in this file).
|
|
1342
|
+
* The operator's tap IS the authorization; the gateway sets the proposal
|
|
1343
|
+
* `approved` BEFORE invoking the applier (whose own status check is
|
|
1344
|
+
* defense-in-depth, MJ2, since the store is agent-writable).
|
|
1345
|
+
*/
|
|
1346
|
+
async function handleEvalCaseProposalCallback(ctx: Context, data: string): Promise<void> {
|
|
1347
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
1348
|
+
const access = loadAccess()
|
|
1349
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
1350
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
1351
|
+
return
|
|
1352
|
+
}
|
|
1353
|
+
const parsed = parseEvalCaseProposalCallback(data)
|
|
1354
|
+
if (parsed == null) {
|
|
1355
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
1356
|
+
return
|
|
1357
|
+
}
|
|
1358
|
+
const stateDir = process.env.TELEGRAM_STATE_DIR
|
|
1359
|
+
const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
1360
|
+
if (stateDir == null || stateDir.length === 0) {
|
|
1361
|
+
await ctx.answerCallbackQuery({ text: 'State dir unset — cannot apply.' }).catch(() => {})
|
|
1362
|
+
return
|
|
1363
|
+
}
|
|
1364
|
+
const proposal = getEvalCaseProposal(stateDir, parsed.id)
|
|
1365
|
+
if (proposal == null) {
|
|
1366
|
+
await ctx.answerCallbackQuery({ text: 'Proposal expired or already actioned.' }).catch(() => {})
|
|
1367
|
+
if (ctx.callbackQuery?.message) {
|
|
1368
|
+
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
1369
|
+
}
|
|
1370
|
+
return
|
|
1371
|
+
}
|
|
1372
|
+
if (proposal.status !== 'pending') {
|
|
1373
|
+
await ctx.answerCallbackQuery({ text: `Already ${proposal.status}.` }).catch(() => {})
|
|
1374
|
+
if (ctx.callbackQuery?.message) {
|
|
1375
|
+
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
1376
|
+
}
|
|
1377
|
+
return
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
if (parsed.action === 'deny') {
|
|
1381
|
+
setEvalCaseProposalStatus(stateDir, parsed.id, 'rejected')
|
|
1382
|
+
await ctx.answerCallbackQuery({ text: '🚫 Dismissed.' }).catch(() => {})
|
|
1383
|
+
if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
|
|
1384
|
+
await ctx
|
|
1385
|
+
.editMessageText(
|
|
1386
|
+
`${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🚫 <i>Dismissed.</i>`,
|
|
1387
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
1388
|
+
)
|
|
1389
|
+
.catch(() => {})
|
|
1390
|
+
}
|
|
1391
|
+
return
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Approve — authorize, then run the deterministic applier.
|
|
1395
|
+
setEvalCaseProposalStatus(stateDir, parsed.id, 'approved')
|
|
1396
|
+
await ctx.answerCallbackQuery({ text: '✅ Adding the eval case…' }).catch(() => {})
|
|
1397
|
+
|
|
1398
|
+
const cli = process.env.SWITCHROOM_CLI_PATH ?? 'switchroom'
|
|
1399
|
+
let applyOk = true
|
|
1400
|
+
let applyOut = ''
|
|
1401
|
+
try {
|
|
1402
|
+
applyOut = execFileSync(
|
|
1403
|
+
cli,
|
|
1404
|
+
['self-improve', 'apply-eval-case', '--id', parsed.id],
|
|
1405
|
+
{ encoding: 'utf8', timeout: 15000, env: process.env },
|
|
1406
|
+
).trim()
|
|
1407
|
+
} catch (err) {
|
|
1408
|
+
applyOk = false
|
|
1409
|
+
const e = err as { stdout?: string; stderr?: string; message?: string }
|
|
1410
|
+
applyOut = [e.stdout, e.stderr, e.message].filter(Boolean).join('\n').trim()
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const footer = applyOk
|
|
1414
|
+
? '✅ <i>Added as a regression test.</i>'
|
|
1415
|
+
: `⚠️ <i>Apply failed:</i> ${escapeHtmlForTg(applyOut.slice(0, 200))}`
|
|
1416
|
+
if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
|
|
1417
|
+
await ctx
|
|
1418
|
+
.editMessageText(
|
|
1419
|
+
`${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n${footer}`,
|
|
1420
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
1421
|
+
)
|
|
1422
|
+
.catch(() => {})
|
|
1423
|
+
}
|
|
1424
|
+
process.stderr.write(
|
|
1425
|
+
`telegram gateway: eval_case_apply agent=${agent} proposal=${proposal.id} ` +
|
|
1426
|
+
`slug=${proposal.skill_slug} ok=${applyOk}\n`,
|
|
1427
|
+
)
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1331
1430
|
/**
|
|
1332
1431
|
* hindsight Phase 5 — handle a tap on the mental-model PROPOSAL card.
|
|
1333
1432
|
* mmp:approve:<stageId> — declare the model: append it to the agent's
|
|
@@ -3233,6 +3332,7 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
|
|
|
3233
3332
|
performVaultAccessApproval,
|
|
3234
3333
|
resolveAccessApprovalPassphraseMismatch,
|
|
3235
3334
|
handleSkillProposalCallback,
|
|
3335
|
+
handleEvalCaseProposalCallback,
|
|
3236
3336
|
handleMentalModelProposeCallback,
|
|
3237
3337
|
handleVaultRequestAccessCallback,
|
|
3238
3338
|
handleVaultRequestSaveCallback,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-tap eval-case proposal card (RFC amendment §"corrections as eval
|
|
3
|
+
* cases").
|
|
4
|
+
*
|
|
5
|
+
* `switchroom self-improve add-eval-case` sends a `post_eval_case_proposal`
|
|
6
|
+
* IPC; the gateway persists it (eval-case-proposals store) and renders THIS
|
|
7
|
+
* card. Unlike the skill-proposal card, tapping Approve does NOT inject a
|
|
8
|
+
* model turn — the gateway callback runs the DETERMINISTIC `apply-eval-case`
|
|
9
|
+
* applier via execFileSync, so the case lands byte-exact as approved.
|
|
10
|
+
*
|
|
11
|
+
* Pure builders, kept out of gateway.ts so the card text + callback shape are
|
|
12
|
+
* pinned by tests independent of the bot plumbing.
|
|
13
|
+
*
|
|
14
|
+
* callback_data shape (must fit Telegram's 64-byte limit):
|
|
15
|
+
* evcase:approve:<id>
|
|
16
|
+
* evcase:deny:<id>
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const EVAL_CASE_PROPOSAL_CALLBACK_PREFIX = 'evcase:'
|
|
20
|
+
|
|
21
|
+
/** Narrow view of a stored eval-case proposal the card needs. */
|
|
22
|
+
export interface EvalCaseProposalView {
|
|
23
|
+
id: string
|
|
24
|
+
skill_slug: string
|
|
25
|
+
held_out: boolean
|
|
26
|
+
case: { prompt: string; expectations?: string[] }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function escapeHtml(s: string): string {
|
|
30
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Truncate a single-line preview of the prompt for the card. */
|
|
34
|
+
function previewPrompt(prompt: string, max = 240): string {
|
|
35
|
+
const oneLine = prompt.replace(/\s+/g, ' ').trim()
|
|
36
|
+
return oneLine.length > max ? oneLine.slice(0, max - 1) + '…' : oneLine
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Render the eval-case proposal card text (HTML parse mode). */
|
|
40
|
+
export function renderEvalCaseProposalCard(p: EvalCaseProposalView): string {
|
|
41
|
+
const lines: string[] = []
|
|
42
|
+
lines.push(`🧪 <b>Eval case proposed</b>`)
|
|
43
|
+
lines.push('')
|
|
44
|
+
lines.push(`<b>Skill:</b> <code>${escapeHtml(p.skill_slug)}</code>`)
|
|
45
|
+
if (p.held_out) lines.push(`<i>held-out (won't be added to evals.json)</i>`)
|
|
46
|
+
lines.push(`<b>Test prompt:</b> ${escapeHtml(previewPrompt(p.case.prompt))}`)
|
|
47
|
+
const exps = p.case.expectations ?? []
|
|
48
|
+
if (exps.length > 0) {
|
|
49
|
+
lines.push('<b>Checks:</b>')
|
|
50
|
+
for (const e of exps.slice(0, 5)) lines.push(`• ${escapeHtml(e.slice(0, 120))}`)
|
|
51
|
+
}
|
|
52
|
+
lines.push('')
|
|
53
|
+
lines.push(
|
|
54
|
+
'<i>Tap Add to append it as a regression test (re-scanned for ' +
|
|
55
|
+
'secrets/PII, written byte-exact — no model turn). Tap Dismiss to drop it.</i>',
|
|
56
|
+
)
|
|
57
|
+
return lines.join('\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Inline keyboard for the card. */
|
|
61
|
+
export function evalCaseProposalKeyboard(id: string): {
|
|
62
|
+
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
|
|
63
|
+
} {
|
|
64
|
+
return {
|
|
65
|
+
inline_keyboard: [
|
|
66
|
+
[
|
|
67
|
+
{ text: '✅ Add case', callback_data: `${EVAL_CASE_PROPOSAL_CALLBACK_PREFIX}approve:${id}` },
|
|
68
|
+
{ text: '🚫 Dismiss', callback_data: `${EVAL_CASE_PROPOSAL_CALLBACK_PREFIX}deny:${id}` },
|
|
69
|
+
],
|
|
70
|
+
],
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Parse an `evcase:` callback into { action, id }, or null if not ours. */
|
|
75
|
+
export function parseEvalCaseProposalCallback(
|
|
76
|
+
data: string,
|
|
77
|
+
): { action: 'approve' | 'deny'; id: string } | null {
|
|
78
|
+
if (!data.startsWith(EVAL_CASE_PROPOSAL_CALLBACK_PREFIX)) return null
|
|
79
|
+
const rest = data.slice(EVAL_CASE_PROPOSAL_CALLBACK_PREFIX.length)
|
|
80
|
+
const idx = rest.indexOf(':')
|
|
81
|
+
if (idx < 0) return null
|
|
82
|
+
const action = rest.slice(0, idx)
|
|
83
|
+
const id = rest.slice(idx + 1)
|
|
84
|
+
if ((action !== 'approve' && action !== 'deny') || id.length === 0) return null
|
|
85
|
+
return { action, id }
|
|
86
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
resetFleetFallbackNoticeCooldowns,
|
|
4
|
+
shouldSendAllBlockedNotice,
|
|
5
|
+
shouldSendStrictPinnedNotice,
|
|
6
|
+
} from './fleet-fallback-notice-cooldown.js'
|
|
7
|
+
|
|
8
|
+
const COOLDOWN_MS = 30 * 60_000
|
|
9
|
+
// Realistic wall-clock base: the initial state is { lastSentAtMs: 0 }, so the
|
|
10
|
+
// first call only "sends" when `now` is at least COOLDOWN_MS past the epoch —
|
|
11
|
+
// always true for a real Date.now(), so anchor the fakes there too.
|
|
12
|
+
const BASE = 1_700_000_000_000
|
|
13
|
+
|
|
14
|
+
function captureStderr(): { lines: string[]; restore: () => void } {
|
|
15
|
+
const lines: string[] = []
|
|
16
|
+
const spy = vi
|
|
17
|
+
.spyOn(process.stderr, 'write')
|
|
18
|
+
.mockImplementation((chunk: string | Uint8Array): boolean => {
|
|
19
|
+
lines.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
|
20
|
+
return true
|
|
21
|
+
})
|
|
22
|
+
return { lines, restore: () => spy.mockRestore() }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
// Reset the module-scope windows so each test starts from a clean gate.
|
|
27
|
+
resetFleetFallbackNoticeCooldowns()
|
|
28
|
+
vi.restoreAllMocks()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('fleet-fallback notice cooldown gates', () => {
|
|
32
|
+
it('sends the first all-blocked card and suppresses a repeat inside the window', () => {
|
|
33
|
+
const { lines, restore } = captureStderr()
|
|
34
|
+
const t0 = BASE
|
|
35
|
+
expect(shouldSendAllBlockedNotice('agentA', t0)).toBe(true)
|
|
36
|
+
// Re-fire well inside the 30-min window: suppressed, with the exact log text.
|
|
37
|
+
expect(shouldSendAllBlockedNotice('agentA', t0 + 60_000)).toBe(false)
|
|
38
|
+
expect(lines).toEqual([
|
|
39
|
+
'telegram gateway: [fleet-fallback] all-blocked card suppressed (cooldown) agent=agentA\n',
|
|
40
|
+
])
|
|
41
|
+
// After the window elapses, it sends again.
|
|
42
|
+
expect(shouldSendAllBlockedNotice('agentA', t0 + COOLDOWN_MS)).toBe(true)
|
|
43
|
+
restore()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('sends the first strict-pinned card and suppresses a repeat inside the window', () => {
|
|
47
|
+
const { lines, restore } = captureStderr()
|
|
48
|
+
const t0 = BASE + 100_000_000
|
|
49
|
+
expect(shouldSendStrictPinnedNotice('agentB', t0)).toBe(true)
|
|
50
|
+
expect(shouldSendStrictPinnedNotice('agentB', t0 + 60_000)).toBe(false)
|
|
51
|
+
expect(lines).toEqual([
|
|
52
|
+
'telegram gateway: [fleet-fallback] strict-pinned card suppressed (cooldown) agent=agentB\n',
|
|
53
|
+
])
|
|
54
|
+
restore()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('keeps the two windows independent — a strict card never suppresses an all-blocked', () => {
|
|
58
|
+
const t0 = BASE + 200_000_000
|
|
59
|
+
expect(shouldSendStrictPinnedNotice('agentC', t0)).toBe(true)
|
|
60
|
+
// All-blocked has its own window and is unaffected by the strict send above.
|
|
61
|
+
expect(shouldSendAllBlockedNotice('agentC', t0 + 1000)).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('reset re-arms both windows so a post-recovery transition emits promptly', () => {
|
|
65
|
+
const t0 = BASE + 300_000_000
|
|
66
|
+
expect(shouldSendAllBlockedNotice('agentD', t0)).toBe(true)
|
|
67
|
+
expect(shouldSendStrictPinnedNotice('agentD', t0)).toBe(true)
|
|
68
|
+
// A successful swap resets both windows.
|
|
69
|
+
resetFleetFallbackNoticeCooldowns()
|
|
70
|
+
// Immediately after reset, both send again despite being inside the window.
|
|
71
|
+
expect(shouldSendAllBlockedNotice('agentD', t0 + 1000)).toBe(true)
|
|
72
|
+
expect(shouldSendStrictPinnedNotice('agentD', t0 + 1000)).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fleet-fallback-notice-cooldown.ts — per-gateway cooldown gates for the two
|
|
3
|
+
* NO-OP fleet-fallback outcomes: "all accounts blocked" and "strict-pinned".
|
|
4
|
+
*
|
|
5
|
+
* Both outcomes make `doFireFleetAutoFallback` return false WITHOUT swapping the
|
|
6
|
+
* active account, so the `fleetFallbackGate` dedup window never arms for them and
|
|
7
|
+
* the ~60s `quota_wall_detected` re-trigger would otherwise re-broadcast the
|
|
8
|
+
* identical card every minute for the life of the wall. Each outcome gets its own
|
|
9
|
+
* per-gateway cooldown window (separate state so a strict-pinned card never
|
|
10
|
+
* suppresses a genuine later all-blocked, or vice versa). A successful swap resets
|
|
11
|
+
* both windows so a fresh transition after recovery is not stale-suppressed.
|
|
12
|
+
*
|
|
13
|
+
* Extracted verbatim from gateway.ts (switchroom#4442) to keep the inline notice
|
|
14
|
+
* logic out of the file the line-ratchet guards. Behaviour is byte-identical to
|
|
15
|
+
* the previous inline gates: same 30-min cooldown window (via
|
|
16
|
+
* `evaluateAllBlockedNotice`), same stderr suppress-log text, same
|
|
17
|
+
* return-false-on-suppress contract.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
evaluateAllBlockedNotice,
|
|
22
|
+
type FallbackAllBlockedNoticeState,
|
|
23
|
+
} from '../auto-fallback-fleet.js'
|
|
24
|
+
|
|
25
|
+
let allBlockedState: FallbackAllBlockedNoticeState = { lastSentAtMs: 0 }
|
|
26
|
+
let strictPinnedState: FallbackAllBlockedNoticeState = { lastSentAtMs: 0 }
|
|
27
|
+
|
|
28
|
+
/** Reset both cooldown windows — called on a successful account swap. */
|
|
29
|
+
export function resetFleetFallbackNoticeCooldowns(): void {
|
|
30
|
+
allBlockedState = { lastSentAtMs: 0 }
|
|
31
|
+
strictPinnedState = { lastSentAtMs: 0 }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Gate the "all accounts blocked" card. Returns true if it should be sent now
|
|
36
|
+
* (advancing the cooldown window); false if suppressed by the window, writing the
|
|
37
|
+
* same stderr suppress log the gateway used inline.
|
|
38
|
+
*/
|
|
39
|
+
export function shouldSendAllBlockedNotice(
|
|
40
|
+
triggerAgent: string,
|
|
41
|
+
now: number = Date.now(),
|
|
42
|
+
): boolean {
|
|
43
|
+
const verdict = evaluateAllBlockedNotice(allBlockedState, now)
|
|
44
|
+
if (!verdict.send) {
|
|
45
|
+
process.stderr.write(
|
|
46
|
+
`telegram gateway: [fleet-fallback] all-blocked card suppressed (cooldown) agent=${triggerAgent}\n`,
|
|
47
|
+
)
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
allBlockedState = verdict.next
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Gate the "strict-pinned" card. Same contract as `shouldSendAllBlockedNotice`,
|
|
56
|
+
* against the separate strict-pinned cooldown window.
|
|
57
|
+
*/
|
|
58
|
+
export function shouldSendStrictPinnedNotice(
|
|
59
|
+
triggerAgent: string,
|
|
60
|
+
now: number = Date.now(),
|
|
61
|
+
): boolean {
|
|
62
|
+
const verdict = evaluateAllBlockedNotice(strictPinnedState, now)
|
|
63
|
+
if (!verdict.send) {
|
|
64
|
+
process.stderr.write(
|
|
65
|
+
`telegram gateway: [fleet-fallback] strict-pinned card suppressed (cooldown) agent=${triggerAgent}\n`,
|
|
66
|
+
)
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
strictPinnedState = verdict.next
|
|
70
|
+
return true
|
|
71
|
+
}
|