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.
Files changed (34) hide show
  1. package/bin/tmp-reaper.sh +234 -0
  2. package/dist/agent-scheduler/index.js +1 -1
  3. package/dist/auth-broker/index.js +2 -2
  4. package/dist/cli/notion-write-pretool.mjs +1 -1
  5. package/dist/cli/switchroom.js +3421 -2744
  6. package/dist/host-control/main.js +177 -13
  7. package/dist/vault/approvals/kernel-server.js +2 -2
  8. package/dist/vault/broker/server.js +2 -2
  9. package/package.json +5 -4
  10. package/profiles/_base/start.sh.hbs +115 -0
  11. package/profiles/_shared/local-time.md.hbs +6 -0
  12. package/profiles/default/CLAUDE.md.hbs +0 -12
  13. package/telegram-plugin/dist/gateway/gateway.js +1017 -465
  14. package/telegram-plugin/gateway/agent-process-liveness.ts +558 -0
  15. package/telegram-plugin/gateway/approval-hold.ts +32 -1
  16. package/telegram-plugin/gateway/approval-outcome-sources.ts +274 -0
  17. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +21 -9
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +87 -15
  19. package/telegram-plugin/gateway/eval-case-proposal-inbound-builders.ts +197 -0
  20. package/telegram-plugin/gateway/gateway.ts +12 -10
  21. package/telegram-plugin/gateway/pending-inbound-buffer.ts +167 -11
  22. package/telegram-plugin/gateway/self-improve-proposal-wiring.test.ts +333 -0
  23. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +152 -3
  24. package/telegram-plugin/gateway/subagent-handback-marker.ts +19 -0
  25. package/telegram-plugin/tests/agent-process-liveness.test.ts +406 -0
  26. package/telegram-plugin/tests/approval-hold-record.test.ts +21 -8
  27. package/telegram-plugin/tests/boot-resume-gateway-only-respawn.test.ts +752 -0
  28. package/telegram-plugin/tests/boot-resume-guard-wiring.test.ts +203 -0
  29. package/telegram-plugin/tests/callback-query-handlers.test.ts +143 -1
  30. package/telegram-plugin/tests/eval-case-proposal-inbound-builders.test.ts +144 -0
  31. package/telegram-plugin/tests/hermes-messages-paging.test.ts +149 -0
  32. package/telegram-plugin/tests/hermes-session-search.test.ts +146 -0
  33. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +443 -2
  34. 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 the
1952
- // wake-audit context. The injected inbound above is the real wake signal;
1953
- // these vars are passive context only. Extracted to pending-turn-env.ts
1954
- // (atomic tmp+rename writer; never throws).
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, the OLDEST entry is dropped
28
- * the assumption is the freshest wake-up is the most relevant. A
29
- * dropped entry is logged via the provided logger.
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
- return {
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
- const dropped = q.shift()
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 oldest entry source=${dropped?.meta?.source ?? '-'} ts=${dropped?.ts ?? '-'}\n`,
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
- if (dropped != null && opts.onEvict != null) {
391
- try {
392
- opts.onEvict(agent, dropped)
393
- } catch {
394
- /* user-facing notice is best-effort; never break the hot path */
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
+ })