switchroom 0.21.7 → 0.21.8
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/bin/tmp-reaper.sh +234 -0
- package/dist/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +2 -2
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +3421 -2744
- package/dist/host-control/main.js +177 -13
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +5 -4
- package/profiles/_base/start.sh.hbs +115 -0
- package/profiles/_shared/local-time.md.hbs +6 -0
- package/profiles/default/CLAUDE.md.hbs +0 -12
- package/telegram-plugin/dist/gateway/gateway.js +1017 -465
- package/telegram-plugin/gateway/agent-process-liveness.ts +558 -0
- package/telegram-plugin/gateway/approval-hold.ts +32 -1
- package/telegram-plugin/gateway/approval-outcome-sources.ts +274 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +21 -9
- package/telegram-plugin/gateway/callback-query-handlers.ts +87 -15
- package/telegram-plugin/gateway/eval-case-proposal-inbound-builders.ts +197 -0
- package/telegram-plugin/gateway/gateway.ts +12 -10
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +167 -11
- package/telegram-plugin/gateway/self-improve-proposal-wiring.test.ts +333 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +152 -3
- package/telegram-plugin/gateway/subagent-handback-marker.ts +19 -0
- package/telegram-plugin/tests/agent-process-liveness.test.ts +406 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +21 -8
- package/telegram-plugin/tests/boot-resume-gateway-only-respawn.test.ts +752 -0
- package/telegram-plugin/tests/boot-resume-guard-wiring.test.ts +203 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +143 -1
- package/telegram-plugin/tests/eval-case-proposal-inbound-builders.test.ts +144 -0
- package/telegram-plugin/tests/hermes-messages-paging.test.ts +149 -0
- package/telegram-plugin/tests/hermes-session-search.test.ts +146 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +443 -2
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +14 -0
|
@@ -979,6 +979,7 @@ import {
|
|
|
979
979
|
} from './resume-inbound-builder.js'
|
|
980
980
|
import { maybeQueueBootBriefing } from './boot-briefing-wiring.js'
|
|
981
981
|
import { writePendingTurnEnv } from './pending-turn-env.js'
|
|
982
|
+
import { shouldSkipBootResumeForGatewayOnlyRespawn, markBootResumeComplete } from './agent-process-liveness.js'
|
|
982
983
|
import {
|
|
983
984
|
createBridgeDeadWatchdog,
|
|
984
985
|
consumeBridgeDeadEscalationMarker,
|
|
@@ -1655,8 +1656,8 @@ let pendingRedelivery: { turn: Turn; maxAgeMs: number } | null = null
|
|
|
1655
1656
|
// #3038 cross-boot damper: consecutive bridge-dead escalations by PRIOR
|
|
1656
1657
|
// boots (the consumed marker's `count`; 0 when no fresh marker). Set in
|
|
1657
1658
|
// the boot block below, consumed by the watchdog constructor further down.
|
|
1658
|
-
let bridgeDeadPriorStreak = 0
|
|
1659
|
-
if (isGatewayMain) try { // #2996 P0c: gated — opens bun:sqlite + writes .pending-turn.env
|
|
1659
|
+
let bridgeDeadPriorStreak = 0; let bootResumeThrew = false // #4641: set by the block's catch below. A SWALLOWED throw is not a completed boot resume — the reaper at the top of the block may already have durably stamped the in-flight turn `ended_via='restart'` while `bootResumeInbound` stayed null — so the generation token must not be stamped on that path. See "WHERE the token is stamped" in agent-process-liveness.ts. (Joined: gateway.ts sits at its line ratchet.)
|
|
1660
|
+
bootResumeInit: if (isGatewayMain) try { // #2996 P0c: gated — opens bun:sqlite + writes .pending-turn.env
|
|
1660
1661
|
// STATE_DIR is `<agentDir>/telegram` in production. openTurnsDb expects
|
|
1661
1662
|
// the parent (agent dir) and joins `telegram/registry.db` itself.
|
|
1662
1663
|
const agentDir = STATE_DIR.endsWith('/telegram')
|
|
@@ -1667,6 +1668,7 @@ if (isGatewayMain) try { // #2996 P0c: gated — opens bun:sqlite + writes .pen
|
|
|
1667
1668
|
// schema; subagents lives alongside in registry.db. Idempotent — safe on
|
|
1668
1669
|
// pre-existing DBs (handles the jsonl_agent_id column migration).
|
|
1669
1670
|
applySubagentsSchema(turnsDb)
|
|
1671
|
+
if (shouldSkipBootResumeForGatewayOnlyRespawn(STATE_DIR)) break bootResumeInit // #4641: this container generation's boot resume is already done — ONLY the gateway respawned. The break skips ALL of: the orphan-turn reaper, consumeBridgeDeadEscalationMarker (already consumed by the gateway that stamped the token), the bridge-dead idle notice, the resume/report synthetic, the pendingRedelivery capture (the turn is still running and will answer itself — redelivering would double-send), the bridgeDeadPriorStreak seed for the #3038 cross-boot damper (left 0: the streak belongs to the gateway that consumed the marker, and re-seeding it here would double-count) and writePendingTurnEnv. Each is safe ONLY because the token proves one gateway already did it this generation; see "What `break bootResumeInit` skips" in agent-process-liveness.ts.
|
|
1670
1672
|
|
|
1671
1673
|
// Read the turn-active marker (the in-flight turn the watchdog tracks)
|
|
1672
1674
|
// BEFORE classifying — its mtime is "ms since last tool progress" and its
|
|
@@ -1948,14 +1950,13 @@ if (isGatewayMain) try { // #2996 P0c: gated — opens bun:sqlite + writes .pen
|
|
|
1948
1950
|
}
|
|
1949
1951
|
}
|
|
1950
1952
|
|
|
1951
|
-
// Diagnostic env file (one-shot, sourced by start.sh) — kept for
|
|
1952
|
-
//
|
|
1953
|
-
//
|
|
1954
|
-
//
|
|
1955
|
-
writePendingTurnEnv(agentDir, pending)
|
|
1953
|
+
// Diagnostic env file (one-shot, sourced by start.sh) — kept for wake-audit
|
|
1954
|
+
// context. The injected inbound above is the real wake signal; these vars are
|
|
1955
|
+
// passive context only. pending-turn-env.ts: atomic writer, never throws.
|
|
1956
|
+
writePendingTurnEnv(agentDir, pending) // #4641: the generation token is deliberately NOT stamped here. This block only builds `bootResumeInbound` IN MEMORY; the resume becomes crash-survivable ~8k lines below, at the `inboundSpool.put` — and that is where the stamp lives. See the comment at that call site.
|
|
1956
1957
|
} catch (err) {
|
|
1957
1958
|
process.stderr.write(`telegram gateway: turn-registry init failed (${(err as Error).message}) — turn tracking disabled\n`)
|
|
1958
|
-
turnsDb = null
|
|
1959
|
+
turnsDb = null; bootResumeThrew = true // #4641: swallow-and-continue must NOT stamp the token — see the declaration above.
|
|
1959
1960
|
}
|
|
1960
1961
|
|
|
1961
1962
|
/**
|
|
@@ -10167,6 +10168,7 @@ if (isGatewayMain && bootResumeInbound != null) {
|
|
|
10167
10168
|
}
|
|
10168
10169
|
}
|
|
10169
10170
|
}
|
|
10171
|
+
if (isGatewayMain && !bootResumeThrew) markBootResumeComplete(STATE_DIR) // #4641 generation token, stamped HERE — AFTER the boot-resume inbound is durably spooled above, never at the tail of the `bootResumeInit` block (which only builds it in memory). Same ordering rule, same reason, as `markTurnResumed` directly above: a crash between the block and this line must leave NO token, so the successor re-mints the resume rather than suppressing it. Unconditional on whether there was anything to resume (a boot that found nothing still completed this generation's boot resume) but NOT on success: a swallowed throw in the block (`bootResumeThrew`) leaves the token unstamped, because the reaper may have durably stamped a turn `ended_via='restart'` that then never got spooled. Full argument: "WHERE the token is stamped" in agent-process-liveness.ts. (One line: gateway.ts sits at its line ratchet — scripts/gateway-line-ratchet.txt.)
|
|
10170
10172
|
// Boot-replay: re-queue every un-acked spooled inbound into the
|
|
10171
10173
|
// in-memory buffer so the existing drain triggers (onClientRegistered
|
|
10172
10174
|
// / silence-poke #1546 / idle-drain #1549) deliver them. push →
|
|
@@ -11633,14 +11635,14 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
11633
11635
|
// fall inside the source slice send-outbound-wiring.test.ts takes between
|
|
11634
11636
|
// onSendOutbound and onQuotaWallDetected.
|
|
11635
11637
|
onPostSkillProposal(_client: IpcClient, msg: PostSkillProposalMessage) {
|
|
11636
|
-
handlePostSkillProposal(msg, { bot, assertAllowedChat, swallowingApiCall })
|
|
11638
|
+
handlePostSkillProposal(msg, { bot, assertAllowedChat, swallowingApiCall, deliverResumeSyntheticOrBuffer })
|
|
11637
11639
|
},
|
|
11638
11640
|
|
|
11639
11641
|
// RFC amendment §"corrections as eval cases" — thin delegate; the
|
|
11640
11642
|
// DETERMINISTIC applier runs on Approve in handleEvalCaseProposalCallback,
|
|
11641
11643
|
// NOT a model turn. Body in self-improve-proposal-wiring.ts.
|
|
11642
11644
|
onPostEvalCaseProposal(_client: IpcClient, msg: PostEvalCaseProposalMessage) {
|
|
11643
|
-
handlePostEvalCaseProposal(msg, { bot, assertAllowedChat, swallowingApiCall })
|
|
11645
|
+
handlePostEvalCaseProposal(msg, { bot, assertAllowedChat, swallowingApiCall, deliverResumeSyntheticOrBuffer })
|
|
11644
11646
|
},
|
|
11645
11647
|
|
|
11646
11648
|
// Buzz Phase 2b: the duplex peer's advisory publish outcome — no-op unless the hub mirror booted.
|
|
@@ -24,19 +24,97 @@
|
|
|
24
24
|
* 5-minute delay, not a permanent stall.
|
|
25
25
|
*
|
|
26
26
|
* Per-agent cap prevents a never-reconnecting bridge from leaking
|
|
27
|
-
* unbounded memory. When the cap is hit
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* unbounded memory. When the cap is hit exactly one entry is dropped and
|
|
28
|
+
* logged via the provided logger. The victim is NOT simply the oldest —
|
|
29
|
+
* see `selectEvictionVictim`. "Oldest wins" was wrong precisely because
|
|
30
|
+
* the oldest entry is the one most likely to be a synthetic approval
|
|
31
|
+
* outcome that has been waiting through an entire turn, and an approval
|
|
32
|
+
* outcome is the one inbound class the operator cannot resend.
|
|
30
33
|
*/
|
|
31
34
|
|
|
32
35
|
import type { InboundMessage } from './ipc-protocol.js'
|
|
33
36
|
import type { InboundSpool } from './inbound-spool.js'
|
|
34
37
|
import { stampsHandbackMarker } from './subagent-handback-marker.js'
|
|
38
|
+
import { createApprovalOutcomeDropNotifier, isApprovalOutcome } from './approval-outcome-sources.js'
|
|
35
39
|
|
|
36
40
|
/** Default cap per agent. Tuned for `should fit a reasonable backlog of
|
|
37
41
|
* approval cards stacked while bridge is offline` but no more. */
|
|
38
42
|
export const DEFAULT_PENDING_INBOUND_CAP = 32
|
|
39
43
|
|
|
44
|
+
/**
|
|
45
|
+
* How long an approval outcome keeps its eviction protection.
|
|
46
|
+
*
|
|
47
|
+
* Matched to the inbound spool's default `escalateAfterMs`
|
|
48
|
+
* (`inbound-spool.ts:286`, 15 min). Past that the spool has already escalated
|
|
49
|
+
* the entry and tombstoned it, so holding a buffer slot for it buys nothing.
|
|
50
|
+
*
|
|
51
|
+
* What this bound is NOT: it is not what keeps the buffer live. Liveness is
|
|
52
|
+
* structural and belongs entirely to tier 3 of `selectEvictionVictim`, which
|
|
53
|
+
* returns `index 0` unconditionally — the buffer can never refuse to evict, so
|
|
54
|
+
* there is no live pin for an age bound to release. An earlier version of this
|
|
55
|
+
* comment claimed otherwise; it was wrong.
|
|
56
|
+
*
|
|
57
|
+
* What it actually buys is victim QUALITY within the all-outcomes tier: given a
|
|
58
|
+
* choice among outcomes, drop one the spool has already escalated rather than
|
|
59
|
+
* one still inside its delivery window. Note this only changes the victim when
|
|
60
|
+
* the queue is NOT in `ts`-ascending order — in normal insertion order the
|
|
61
|
+
* oldest entry is also the stalest, so tier 2 and tier 3 select the same index
|
|
62
|
+
* and differ only in the `reason` they report. The test `selectEvictionVictim
|
|
63
|
+
* tiers > prefers a STALE outcome over a fresher one queued ahead of it` pins
|
|
64
|
+
* the case where they diverge; without it, deleting this bound is invisible.
|
|
65
|
+
*/
|
|
66
|
+
export const APPROVAL_OUTCOME_PROTECTION_MS = 15 * 60 * 1000
|
|
67
|
+
|
|
68
|
+
/** Why `selectEvictionVictim` picked the entry it picked. */
|
|
69
|
+
export type EvictionReason = 'non-outcome' | 'stale-outcome' | 'all-outcomes'
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Choose which buffered entry to evict when the cap is hit. Pure, so the
|
|
73
|
+
* tiering can be pinned exhaustively.
|
|
74
|
+
*
|
|
75
|
+
* Three tiers, in order:
|
|
76
|
+
* 1. The oldest entry that is NOT an approval outcome. An ordinary chat
|
|
77
|
+
* message is resendable and its loss is already surfaced by the
|
|
78
|
+
* coalesced `onEvict` notice; an approval outcome is not resendable at
|
|
79
|
+
* all (see approval-outcome-sources.ts). In every realistic overflow —
|
|
80
|
+
* a burst of user messages while a turn is in flight — this tier hits,
|
|
81
|
+
* so an outcome can no longer be evicted out from under an agent that
|
|
82
|
+
* is blocked on it.
|
|
83
|
+
* 2. The oldest outcome past `protectMaxAgeMs` — one the spool has already
|
|
84
|
+
* escalated, so it is the least costly outcome to lose. This tier is a
|
|
85
|
+
* victim-QUALITY preference, NOT a liveness guarantee (tier 3 supplies
|
|
86
|
+
* liveness); it only changes the choice when the queue is out of `ts`
|
|
87
|
+
* order, since otherwise the oldest entry is also the stalest.
|
|
88
|
+
* 3. Pathological: every entry is a fresh outcome. The oldest goes and the
|
|
89
|
+
* caller is told via `onEvictCritical` — a dropped outcome the agent is
|
|
90
|
+
* never informed of is a permanent block, which is strictly worse than
|
|
91
|
+
* a dropped outcome it knows about. This tier returns UNCONDITIONALLY,
|
|
92
|
+
* which is what guarantees the buffer always makes progress.
|
|
93
|
+
*
|
|
94
|
+
* Insertion order of the SURVIVORS is unchanged in every tier (a splice of
|
|
95
|
+
* one element preserves relative order), so the FIFO contract `drain` and
|
|
96
|
+
* `planBufferedRedelivery` rely on still holds.
|
|
97
|
+
*
|
|
98
|
+
* Returns index 0 on an empty queue; the caller's `splice` then yields
|
|
99
|
+
* `undefined` and is guarded, matching the previous `q.shift()` behaviour.
|
|
100
|
+
*/
|
|
101
|
+
export function selectEvictionVictim(
|
|
102
|
+
q: readonly InboundMessage[],
|
|
103
|
+
nowMs: number,
|
|
104
|
+
protectMaxAgeMs: number = APPROVAL_OUTCOME_PROTECTION_MS,
|
|
105
|
+
): { index: number; reason: EvictionReason } {
|
|
106
|
+
for (let i = 0; i < q.length; i++) {
|
|
107
|
+
if (!isApprovalOutcome(q[i]!)) return { index: i, reason: 'non-outcome' }
|
|
108
|
+
}
|
|
109
|
+
for (let i = 0; i < q.length; i++) {
|
|
110
|
+
const ts = q[i]!.ts
|
|
111
|
+
if (typeof ts === 'number' && Number.isFinite(ts) && nowMs - ts >= protectMaxAgeMs) {
|
|
112
|
+
return { index: i, reason: 'stale-outcome' }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { index: 0, reason: 'all-outcomes' }
|
|
116
|
+
}
|
|
117
|
+
|
|
40
118
|
export interface PendingInboundBuffer {
|
|
41
119
|
/** Append `msg` to `agent`'s queue. Returns true if accepted, false if
|
|
42
120
|
* the cap forced an eviction (the message is STILL accepted; `false`
|
|
@@ -88,8 +166,46 @@ export interface PendingInboundBufferOptions {
|
|
|
88
166
|
* drop into a visible deferral (chat-is-the-single-source-of-truth:
|
|
89
167
|
* surface the loss window, don't hide it). Best-effort: a throw here
|
|
90
168
|
* never breaks the push hot path.
|
|
169
|
+
*
|
|
170
|
+
* Fires ONLY for a non-outcome eviction. When the victim is an approval
|
|
171
|
+
* outcome, `onEvictCritical` fires INSTEAD — the two notices would
|
|
172
|
+
* contradict each other otherwise. `onEvict`'s copy promises the message is
|
|
173
|
+
* "saved and will be handled … I'll ask you to resend it", which is exactly
|
|
174
|
+
* the promise that cannot be kept for a dropped approval outcome (the
|
|
175
|
+
* operator's tap already happened; there is nothing to resend).
|
|
91
176
|
*/
|
|
92
177
|
onEvict?: (agent: string, evicted: InboundMessage) => void
|
|
178
|
+
/**
|
|
179
|
+
* DEFAULTS ON — leave it unset and `createPendingInboundBuffer` wires
|
|
180
|
+
* `createApprovalOutcomeDropNotifier` against its own `push`. Set it to
|
|
181
|
+
* override the notice, or to an explicit no-op to opt out.
|
|
182
|
+
*
|
|
183
|
+
* Called instead of `onEvict` when the evicted entry is an APPROVAL OUTCOME
|
|
184
|
+
* — either a stale one past `approvalOutcomeProtectionMs`, or the
|
|
185
|
+
* pathological case where every buffered entry is a fresh outcome so there
|
|
186
|
+
* is nothing else to drop.
|
|
187
|
+
*
|
|
188
|
+
* This is the one eviction the spool cannot make good on: `sweepEscalations`
|
|
189
|
+
* (`inbound-spool.ts:564-600`) tombstones and posts "couldn't deliver, please
|
|
190
|
+
* resend", which is meaningless for a `vault_grant_approved`. The gateway
|
|
191
|
+
* wires this to a distinct greppable log plus a synthetic notice inbound so
|
|
192
|
+
* the agent learns the outcome is gone rather than blocking on it forever.
|
|
193
|
+
*
|
|
194
|
+
* RE-ENTRANCY: the handler MUST NOT push into this buffer inside the call
|
|
195
|
+
* frame — it is invoked from the middle of `push`, on a queue that is at cap.
|
|
196
|
+
* Defer with `queueMicrotask`. The `try/catch` around this call protects the
|
|
197
|
+
* hot path from a throw; it does nothing against recursion.
|
|
198
|
+
*
|
|
199
|
+
* Best-effort: a throw here never breaks the push hot path.
|
|
200
|
+
*/
|
|
201
|
+
onEvictCritical?: (agent: string, evicted: InboundMessage) => void
|
|
202
|
+
/**
|
|
203
|
+
* How long an approval outcome resists eviction. Defaults to
|
|
204
|
+
* `APPROVAL_OUTCOME_PROTECTION_MS` (the spool's escalation window).
|
|
205
|
+
*/
|
|
206
|
+
approvalOutcomeProtectionMs?: number
|
|
207
|
+
/** Clock seam for the age bound. Defaults to `Date.now`. */
|
|
208
|
+
now?: () => number
|
|
93
209
|
/**
|
|
94
210
|
* fix/backstop-duplicate-reply MUST-FIX 2 — called on every push of a
|
|
95
211
|
* `subagent_handback` envelope (live synthesis AND boot-replay re-push),
|
|
@@ -364,9 +480,11 @@ export function createPendingInboundBuffer(
|
|
|
364
480
|
const cap = opts.capPerAgent ?? DEFAULT_PENDING_INBOUND_CAP
|
|
365
481
|
const log = opts.log ?? ((line: string) => process.stderr.write(line))
|
|
366
482
|
const spool = opts.spool
|
|
483
|
+
const now = opts.now ?? (() => Date.now())
|
|
484
|
+
const protectionMs = opts.approvalOutcomeProtectionMs ?? APPROVAL_OUTCOME_PROTECTION_MS
|
|
367
485
|
const queues = new Map<string, InboundMessage[]>()
|
|
368
486
|
|
|
369
|
-
|
|
487
|
+
const buffer: PendingInboundBuffer = {
|
|
370
488
|
push(agent, msg) {
|
|
371
489
|
let q = queues.get(agent)
|
|
372
490
|
if (q == null) {
|
|
@@ -375,11 +493,17 @@ export function createPendingInboundBuffer(
|
|
|
375
493
|
}
|
|
376
494
|
let evicted = false
|
|
377
495
|
if (q.length >= cap) {
|
|
378
|
-
|
|
496
|
+
// Victim selection is TIERED, not `q.shift()`. The oldest entry is
|
|
497
|
+
// exactly the one most likely to be an approval outcome that has been
|
|
498
|
+
// waiting through an entire turn — and an approval outcome is the one
|
|
499
|
+
// inbound class the operator cannot resend. See selectEvictionVictim.
|
|
500
|
+
const { index, reason } = selectEvictionVictim(q, now(), protectionMs)
|
|
501
|
+
const dropped = q.splice(index, 1)[0]
|
|
379
502
|
evicted = true
|
|
380
503
|
log(
|
|
381
504
|
`pending-inbound-buffer: agent=${agent} cap=${cap} reached — ` +
|
|
382
|
-
`dropped
|
|
505
|
+
`dropped entry idx=${index} reason=${reason} ` +
|
|
506
|
+
`source=${dropped?.meta?.source ?? '-'} ts=${dropped?.ts ?? '-'}\n`,
|
|
383
507
|
)
|
|
384
508
|
// #2789 A: the cap eviction is no longer a SILENT in-session
|
|
385
509
|
// drop. `dropped` still lives in the durable spool (it was
|
|
@@ -387,11 +511,19 @@ export function createPendingInboundBuffer(
|
|
|
387
511
|
// escalation — but it won't be re-delivered THIS session. Hand
|
|
388
512
|
// it to the caller so a coalesced "N messages deferred" notice
|
|
389
513
|
// can be surfaced. Best-effort: never let the notice break push.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
514
|
+
//
|
|
515
|
+
// An APPROVAL OUTCOME victim routes to `onEvictCritical` instead: the
|
|
516
|
+
// "saved, I'll ask you to resend" copy behind `onEvict` is a promise
|
|
517
|
+
// that cannot be kept for a verdict the operator already tapped.
|
|
518
|
+
if (dropped != null) {
|
|
519
|
+
const critical = reason !== 'non-outcome'
|
|
520
|
+
const cb = critical ? onEvictCritical : opts.onEvict
|
|
521
|
+
if (cb != null) {
|
|
522
|
+
try {
|
|
523
|
+
cb(agent, dropped)
|
|
524
|
+
} catch {
|
|
525
|
+
/* user-facing notice is best-effort; never break the hot path */
|
|
526
|
+
}
|
|
395
527
|
}
|
|
396
528
|
}
|
|
397
529
|
}
|
|
@@ -453,4 +585,28 @@ export function createPendingInboundBuffer(
|
|
|
453
585
|
},
|
|
454
586
|
beforeRedeliver: opts.beforeRedeliver,
|
|
455
587
|
}
|
|
588
|
+
|
|
589
|
+
// The critical-eviction notifier DEFAULTS ON, wired to this buffer's own
|
|
590
|
+
// `push`. Same reasoning as `beforeRedeliver` and the handback-marker stamp
|
|
591
|
+
// above: the guarantee we want is "a dropped approval outcome is never
|
|
592
|
+
// silent", and a guarantee that depends on each construction site
|
|
593
|
+
// remembering to pass a callback is discipline, not a mechanism. Defaulting
|
|
594
|
+
// it here makes it true by construction at every site, including future ones.
|
|
595
|
+
//
|
|
596
|
+
// A caller may still override to route the notice elsewhere. Passing an
|
|
597
|
+
// explicit no-op is the (deliberately explicit) way to opt out.
|
|
598
|
+
//
|
|
599
|
+
// The buffer can self-wire because it owns `push` — no caller plumbing, and
|
|
600
|
+
// notably no new inline code in gateway.ts, which is under a hard line
|
|
601
|
+
// ratchet (`scripts/check-gateway-line-ratchet.mjs`) with 2 lines of grace.
|
|
602
|
+
const onEvictCritical =
|
|
603
|
+
opts.onEvictCritical ??
|
|
604
|
+
createApprovalOutcomeDropNotifier({
|
|
605
|
+
push: (a, m) => {
|
|
606
|
+
buffer.push(a, m)
|
|
607
|
+
},
|
|
608
|
+
log,
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
return buffer
|
|
456
612
|
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the self-improvement proposal IPC handlers.
|
|
3
|
+
*
|
|
4
|
+
* The handlers are factored out of gateway.ts so they can be exercised
|
|
5
|
+
* without booting grammy — `bot`, `assertAllowedChat` and
|
|
6
|
+
* `swallowingApiCall` all come in through the ProposalWiringDeps seam, and
|
|
7
|
+
* the proposal stores are real files under a fresh tmpdir.
|
|
8
|
+
*
|
|
9
|
+
* The assertions here are OBSERVABLE: a suppressed proposal writes NO new
|
|
10
|
+
* record to the store and posts NO card (`swallowingApiCall` never fires).
|
|
11
|
+
* "The suppression branch ran" is not a test.
|
|
12
|
+
*
|
|
13
|
+
* Run with: npx vitest run telegram-plugin/gateway/self-improve-proposal-wiring.test.ts
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
17
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
18
|
+
import { tmpdir } from 'node:os'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import type { Bot, Context } from 'grammy'
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
handlePostSkillProposal,
|
|
24
|
+
handlePostEvalCaseProposal,
|
|
25
|
+
isEvalCaseProposalSuppressed,
|
|
26
|
+
type ProposalWiringDeps,
|
|
27
|
+
} from './self-improve-proposal-wiring.js'
|
|
28
|
+
import type {
|
|
29
|
+
PostSkillProposalMessage,
|
|
30
|
+
PostEvalCaseProposalMessage,
|
|
31
|
+
InboundMessage,
|
|
32
|
+
} from './ipc-protocol.js'
|
|
33
|
+
import {
|
|
34
|
+
enqueueProposal as enqueueSkillProposal,
|
|
35
|
+
setProposalStatus as setSkillProposalStatus,
|
|
36
|
+
REJECTION_TTL_MS,
|
|
37
|
+
} from '../../src/self-improve/skill-proposals.js'
|
|
38
|
+
import {
|
|
39
|
+
enqueueEvalCaseProposal,
|
|
40
|
+
readEvalCaseProposals,
|
|
41
|
+
setEvalCaseProposalStatus,
|
|
42
|
+
} from '../../src/self-improve/eval-case-proposals.js'
|
|
43
|
+
|
|
44
|
+
const CHAT = '424242'
|
|
45
|
+
|
|
46
|
+
function makeDeps(): {
|
|
47
|
+
deps: ProposalWiringDeps
|
|
48
|
+
sent: string[]
|
|
49
|
+
woken: Array<{ agent: string; inbound: InboundMessage }>
|
|
50
|
+
} {
|
|
51
|
+
const sent: string[] = []
|
|
52
|
+
const woken: Array<{ agent: string; inbound: InboundMessage }> = []
|
|
53
|
+
const bot = {
|
|
54
|
+
api: {
|
|
55
|
+
sendMessage: vi.fn(async (_chat: string, text: string) => {
|
|
56
|
+
sent.push(text)
|
|
57
|
+
return { message_id: 1 }
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
} as unknown as Bot<Context>
|
|
61
|
+
const deps: ProposalWiringDeps = {
|
|
62
|
+
bot,
|
|
63
|
+
assertAllowedChat: () => {},
|
|
64
|
+
swallowingApiCall: async <T>(fn: () => Promise<T>) => await fn(),
|
|
65
|
+
deliverResumeSyntheticOrBuffer: (agent, inbound) => {
|
|
66
|
+
woken.push({ agent, inbound })
|
|
67
|
+
return true
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
return { deps, sent, woken }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const evalCase = {
|
|
74
|
+
prompt: 'When the operator says "ship it", run the smoke suite first.',
|
|
75
|
+
expectations: ['runs the smoke suite before deploying'],
|
|
76
|
+
source: 'correction 2026-08-13',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function evalMsg(
|
|
80
|
+
over: Partial<PostEvalCaseProposalMessage> = {},
|
|
81
|
+
): PostEvalCaseProposalMessage {
|
|
82
|
+
return {
|
|
83
|
+
type: 'post_eval_case_proposal',
|
|
84
|
+
agentName: 'carrie',
|
|
85
|
+
chatId: CHAT,
|
|
86
|
+
skillSlug: 'deploy-checklist',
|
|
87
|
+
skillDir: '/skills/deploy-checklist',
|
|
88
|
+
case: evalCase,
|
|
89
|
+
fingerprint: 'aaaa1111',
|
|
90
|
+
heldOut: false,
|
|
91
|
+
...over,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const skillDraft = {
|
|
96
|
+
'SKILL.md':
|
|
97
|
+
'---\nname: deploy-checklist\ndescription: deploy steps\n---\n\n' +
|
|
98
|
+
'1. run smoke suite\n2. check dashboards\n3. promote\n',
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function skillMsg(
|
|
102
|
+
over: Partial<PostSkillProposalMessage> = {},
|
|
103
|
+
): PostSkillProposalMessage {
|
|
104
|
+
return {
|
|
105
|
+
type: 'post_skill_proposal',
|
|
106
|
+
agentName: 'carrie',
|
|
107
|
+
chatId: CHAT,
|
|
108
|
+
skillSlug: 'deploy-checklist',
|
|
109
|
+
isNew: true,
|
|
110
|
+
lesson: 'Always run the smoke suite before promoting a deploy',
|
|
111
|
+
draft: skillDraft,
|
|
112
|
+
evidence: 'seen across 3 sessions',
|
|
113
|
+
...over,
|
|
114
|
+
} as PostSkillProposalMessage
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe('self-improve proposal wiring — rejection suppression', () => {
|
|
118
|
+
let dir: string
|
|
119
|
+
const savedStateDir = process.env.TELEGRAM_STATE_DIR
|
|
120
|
+
const savedAgent = process.env.SWITCHROOM_AGENT_NAME
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
dir = mkdtempSync(join(tmpdir(), 'proposal-wiring-'))
|
|
124
|
+
process.env.TELEGRAM_STATE_DIR = dir
|
|
125
|
+
delete process.env.SWITCHROOM_AGENT_NAME
|
|
126
|
+
})
|
|
127
|
+
afterEach(() => {
|
|
128
|
+
rmSync(dir, { recursive: true, force: true })
|
|
129
|
+
if (savedStateDir == null) delete process.env.TELEGRAM_STATE_DIR
|
|
130
|
+
else process.env.TELEGRAM_STATE_DIR = savedStateDir
|
|
131
|
+
if (savedAgent == null) delete process.env.SWITCHROOM_AGENT_NAME
|
|
132
|
+
else process.env.SWITCHROOM_AGENT_NAME = savedAgent
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// ── the sibling skill path (the shape the eval path must mirror) ──
|
|
136
|
+
|
|
137
|
+
it('does not re-post a skill proposal the operator already dismissed', () => {
|
|
138
|
+
const p = enqueueSkillProposal(dir, {
|
|
139
|
+
skill_slug: 'deploy-checklist',
|
|
140
|
+
is_new: true,
|
|
141
|
+
lesson: skillMsg().lesson,
|
|
142
|
+
draft: skillDraft,
|
|
143
|
+
evidence: 'x',
|
|
144
|
+
})
|
|
145
|
+
setSkillProposalStatus(dir, p.id, 'rejected')
|
|
146
|
+
|
|
147
|
+
const { deps, sent } = makeDeps()
|
|
148
|
+
handlePostSkillProposal(skillMsg(), deps)
|
|
149
|
+
|
|
150
|
+
expect(sent).toEqual([])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// ── eval-case path: the defect this PR fixes ──
|
|
154
|
+
|
|
155
|
+
it('does not enqueue or post an eval case whose fingerprint was dismissed', () => {
|
|
156
|
+
const p = enqueueEvalCaseProposal(dir, {
|
|
157
|
+
skill_slug: 'deploy-checklist',
|
|
158
|
+
skill_dir: '/skills/deploy-checklist',
|
|
159
|
+
case: evalCase,
|
|
160
|
+
fingerprint: 'aaaa1111',
|
|
161
|
+
held_out: false,
|
|
162
|
+
})
|
|
163
|
+
setEvalCaseProposalStatus(dir, p.id, 'rejected')
|
|
164
|
+
const before = readEvalCaseProposals(dir).length
|
|
165
|
+
|
|
166
|
+
const { deps, sent } = makeDeps()
|
|
167
|
+
handlePostEvalCaseProposal(evalMsg(), deps)
|
|
168
|
+
|
|
169
|
+
// Observable 1: no card posted.
|
|
170
|
+
expect(sent).toEqual([])
|
|
171
|
+
// Observable 2: no new record written to the store.
|
|
172
|
+
expect(readEvalCaseProposals(dir)).toHaveLength(before)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// ── the suppressed exit must not be SILENT (cross-PR #4662 + #4664) ──
|
|
176
|
+
//
|
|
177
|
+
// #4662 makes the agent's contract "propose, end your turn, wait for the
|
|
178
|
+
// outcome inbound". This branch posts no card, so without a wake-up the agent
|
|
179
|
+
// waits forever — the exact silent block #4662 exists to eliminate. Remove the
|
|
180
|
+
// `deliverResumeSyntheticOrBuffer` call in the suppressed branch and this test
|
|
181
|
+
// fails with `woken` empty: the agent is left with no outcome.
|
|
182
|
+
|
|
183
|
+
it('a suppressed eval case still WAKES the agent — the silent exit is the bug', () => {
|
|
184
|
+
const p = enqueueEvalCaseProposal(dir, {
|
|
185
|
+
skill_slug: 'deploy-checklist',
|
|
186
|
+
skill_dir: '/skills/deploy-checklist',
|
|
187
|
+
case: evalCase,
|
|
188
|
+
fingerprint: 'aaaa1111',
|
|
189
|
+
held_out: false,
|
|
190
|
+
})
|
|
191
|
+
setEvalCaseProposalStatus(dir, p.id, 'rejected')
|
|
192
|
+
|
|
193
|
+
const { deps, sent, woken } = makeDeps()
|
|
194
|
+
handlePostEvalCaseProposal(evalMsg({ threadId: 7 }), deps)
|
|
195
|
+
|
|
196
|
+
// No card — that part is unchanged.
|
|
197
|
+
expect(sent).toEqual([])
|
|
198
|
+
// But the agent is TOLD, exactly once, and routed back to its own topic.
|
|
199
|
+
expect(woken).toHaveLength(1)
|
|
200
|
+
expect(woken[0]!.agent).toBe('carrie')
|
|
201
|
+
expect(woken[0]!.inbound.meta.source).toBe('eval_case_suppressed')
|
|
202
|
+
expect(woken[0]!.inbound.meta.skill_slug).toBe('deploy-checklist')
|
|
203
|
+
expect(woken[0]!.inbound.meta.fingerprint).toBe('aaaa1111')
|
|
204
|
+
expect(woken[0]!.inbound.threadId).toBe(7)
|
|
205
|
+
expect(woken[0]!.inbound.meta.message_thread_id).toBe('7')
|
|
206
|
+
// The instruction that keeps the agent from waiting or retrying.
|
|
207
|
+
expect(woken[0]!.inbound.text).toContain('do NOT wait')
|
|
208
|
+
expect(woken[0]!.inbound.text).toContain('Do NOT re-propose')
|
|
209
|
+
// Suppression is the system working, not a failure — an agent told only
|
|
210
|
+
// "no card" would reasonably retry, which is the loop this PR removes.
|
|
211
|
+
expect(woken[0]!.inbound.text).toContain('EXPECTED')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('a NORMAL (unsuppressed) proposal posts a card and does NOT wake the agent', () => {
|
|
215
|
+
const { deps, sent, woken } = makeDeps()
|
|
216
|
+
handlePostEvalCaseProposal(evalMsg(), deps)
|
|
217
|
+
|
|
218
|
+
// Proves the wake above is specific to the suppressed branch, not something
|
|
219
|
+
// this handler does on every call.
|
|
220
|
+
expect(sent).toHaveLength(1)
|
|
221
|
+
expect(woken).toEqual([])
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('never suppresses on an empty fingerprint — it carries no identity', () => {
|
|
225
|
+
// A stored rejection with an empty fingerprint must not swallow a later
|
|
226
|
+
// proposal that also lacks one: two different cases, no shared identity.
|
|
227
|
+
const p = enqueueEvalCaseProposal(dir, {
|
|
228
|
+
skill_slug: 'deploy-checklist',
|
|
229
|
+
skill_dir: '/skills/deploy-checklist',
|
|
230
|
+
case: evalCase,
|
|
231
|
+
fingerprint: '',
|
|
232
|
+
held_out: false,
|
|
233
|
+
})
|
|
234
|
+
setEvalCaseProposalStatus(dir, p.id, 'rejected')
|
|
235
|
+
|
|
236
|
+
expect(
|
|
237
|
+
isEvalCaseProposalSuppressed(dir, {
|
|
238
|
+
skillSlug: 'deploy-checklist',
|
|
239
|
+
fingerprint: '',
|
|
240
|
+
}),
|
|
241
|
+
).toBe(false)
|
|
242
|
+
|
|
243
|
+
// …and the handler still posts the card rather than silently dropping it.
|
|
244
|
+
const { deps, sent } = makeDeps()
|
|
245
|
+
handlePostEvalCaseProposal(evalMsg({ fingerprint: '' }), deps)
|
|
246
|
+
expect(sent).toHaveLength(1)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('still enqueues and posts an eval case with a different fingerprint', () => {
|
|
250
|
+
const p = enqueueEvalCaseProposal(dir, {
|
|
251
|
+
skill_slug: 'deploy-checklist',
|
|
252
|
+
skill_dir: '/skills/deploy-checklist',
|
|
253
|
+
case: { prompt: 'something else entirely' },
|
|
254
|
+
fingerprint: 'bbbb2222',
|
|
255
|
+
held_out: false,
|
|
256
|
+
})
|
|
257
|
+
setEvalCaseProposalStatus(dir, p.id, 'rejected')
|
|
258
|
+
|
|
259
|
+
const { deps, sent } = makeDeps()
|
|
260
|
+
handlePostEvalCaseProposal(evalMsg({ fingerprint: 'aaaa1111' }), deps)
|
|
261
|
+
|
|
262
|
+
expect(sent).toHaveLength(1)
|
|
263
|
+
expect(
|
|
264
|
+
readEvalCaseProposals(dir).some(
|
|
265
|
+
(r) => r.fingerprint === 'aaaa1111' && r.status === 'pending',
|
|
266
|
+
),
|
|
267
|
+
).toBe(true)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('still enqueues the same fingerprint for a DIFFERENT skill slug', () => {
|
|
271
|
+
const p = enqueueEvalCaseProposal(dir, {
|
|
272
|
+
skill_slug: 'other-skill',
|
|
273
|
+
skill_dir: '/skills/other-skill',
|
|
274
|
+
case: evalCase,
|
|
275
|
+
fingerprint: 'aaaa1111',
|
|
276
|
+
held_out: false,
|
|
277
|
+
})
|
|
278
|
+
setEvalCaseProposalStatus(dir, p.id, 'rejected')
|
|
279
|
+
|
|
280
|
+
const { deps, sent } = makeDeps()
|
|
281
|
+
handlePostEvalCaseProposal(evalMsg(), deps)
|
|
282
|
+
|
|
283
|
+
expect(sent).toHaveLength(1)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('a PENDING or APPROVED proposal does not suppress a re-proposal', () => {
|
|
287
|
+
enqueueEvalCaseProposal(dir, {
|
|
288
|
+
skill_slug: 'deploy-checklist',
|
|
289
|
+
skill_dir: '/skills/deploy-checklist',
|
|
290
|
+
case: evalCase,
|
|
291
|
+
fingerprint: 'aaaa1111',
|
|
292
|
+
held_out: false,
|
|
293
|
+
})
|
|
294
|
+
const approved = enqueueEvalCaseProposal(dir, {
|
|
295
|
+
skill_slug: 'deploy-checklist',
|
|
296
|
+
skill_dir: '/skills/deploy-checklist',
|
|
297
|
+
case: evalCase,
|
|
298
|
+
fingerprint: 'cccc3333',
|
|
299
|
+
held_out: false,
|
|
300
|
+
})
|
|
301
|
+
setEvalCaseProposalStatus(dir, approved.id, 'approved')
|
|
302
|
+
|
|
303
|
+
expect(isEvalCaseProposalSuppressed(dir, evalMsg())).toBe(false)
|
|
304
|
+
expect(
|
|
305
|
+
isEvalCaseProposalSuppressed(dir, evalMsg({ fingerprint: 'cccc3333' })),
|
|
306
|
+
).toBe(false)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('suppression expires after the rejection TTL', () => {
|
|
310
|
+
const t0 = Date.UTC(2026, 0, 1)
|
|
311
|
+
const p = enqueueEvalCaseProposal(
|
|
312
|
+
dir,
|
|
313
|
+
{
|
|
314
|
+
skill_slug: 'deploy-checklist',
|
|
315
|
+
skill_dir: '/skills/deploy-checklist',
|
|
316
|
+
case: evalCase,
|
|
317
|
+
fingerprint: 'aaaa1111',
|
|
318
|
+
held_out: false,
|
|
319
|
+
},
|
|
320
|
+
{ now: () => t0 },
|
|
321
|
+
)
|
|
322
|
+
setEvalCaseProposalStatus(dir, p.id, 'rejected')
|
|
323
|
+
|
|
324
|
+
expect(
|
|
325
|
+
isEvalCaseProposalSuppressed(dir, evalMsg(), { now: () => t0 + 1000 }),
|
|
326
|
+
).toBe(true)
|
|
327
|
+
expect(
|
|
328
|
+
isEvalCaseProposalSuppressed(dir, evalMsg(), {
|
|
329
|
+
now: () => t0 + REJECTION_TTL_MS + 1,
|
|
330
|
+
}),
|
|
331
|
+
).toBe(false)
|
|
332
|
+
})
|
|
333
|
+
})
|