switchroom 0.14.9 → 0.14.11
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 +161 -157
- package/dist/auth-broker/index.js +82 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +84 -82
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +1053 -917
- package/dist/host-control/main.js +150 -148
- package/dist/vault/approvals/kernel-server.js +84 -82
- package/dist/vault/broker/server.js +85 -83
- package/package.json +3 -3
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +1218 -634
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/boot-card.ts +15 -11
- package/telegram-plugin/gateway/gateway.ts +94 -67
- package/telegram-plugin/gateway/webhook-ingest-server.test.ts +125 -0
- package/telegram-plugin/gateway/webhook-ingest-server.ts +218 -0
- package/telegram-plugin/runtime-metrics.ts +8 -52
- package/telegram-plugin/silence-poke.ts +39 -312
- package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +27 -30
- package/telegram-plugin/tests/silence-poke.test.ts +54 -569
- package/telegram-plugin/uat/scenarios/jtbd-fast-ack-dm.test.ts +21 -23
- package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +0 -155
|
@@ -1,50 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* silence-poke.ts — framework safety net for
|
|
2
|
+
* silence-poke.ts — framework safety net for a genuinely wedged turn.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Scope (post-#1981-era retirement): this module is now ONLY the
|
|
5
|
+
* last-resort unwedge. The conversational pacing the user actually sees
|
|
6
|
+
* — the acknowledgement beat, the "still going" progress updates — is
|
|
7
|
+
* owned by the live-updating reply/draft (the chat IS the artifact) and
|
|
8
|
+
* by the model's own `reply`/`stream_reply` calls, not by framework
|
|
9
|
+
* nudges. The earlier model-targeted nudge ladder (ack at 10s, soft at
|
|
10
|
+
* 75s, firm at 180s) and the 60s user-visible awareness ping were
|
|
11
|
+
* retired: their success rate was 0-7% by the design's own KPI, and they
|
|
12
|
+
* duplicated a job the draft thinking-lane now does natively. See
|
|
13
|
+
* `reference/conversational-pacing.md` § Safety net.
|
|
10
14
|
*
|
|
11
|
-
*
|
|
12
|
-
* idle stall in status-reactions.ts and is unrelated):
|
|
15
|
+
* What remains: ONE silence clock and ONE terminal action.
|
|
13
16
|
*
|
|
14
17
|
* silence clock = now - lastOutboundAt (or turnStartedAt if no outbound yet)
|
|
15
18
|
*
|
|
16
19
|
* Outbound = a fresh `reply` or `stream_reply` first-emit. Reactions,
|
|
17
|
-
* edits, and tool churn DO NOT reset the silence clock —
|
|
18
|
-
*
|
|
19
|
-
* still be "silent" to the user.
|
|
20
|
+
* edits, and tool churn DO NOT reset the silence clock — the model could
|
|
21
|
+
* be ripping through 20 tool calls and still be "silent" to the user.
|
|
20
22
|
*
|
|
21
|
-
*
|
|
23
|
+
* Terminal action, once per turn:
|
|
22
24
|
*
|
|
23
25
|
* t=0 startTurn() — silence clock starts at turnStartedAt
|
|
24
|
-
* t=
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* t=300s framework fallback: the gateway sends a user-visible
|
|
27
|
+
* "still working… / still thinking…" message AND unwedges the
|
|
28
|
+
* turn (clears activeTurnStartedAt, nulls currentTurn, drains
|
|
29
|
+
* buffered inbound, purges stale turn state). This is the
|
|
30
|
+
* load-bearing unwedge primitive — NOT a nudge — that keeps a
|
|
31
|
+
* dead turn from pinning the conversation forever.
|
|
30
32
|
*
|
|
31
|
-
*
|
|
32
|
-
* (
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* Wired into the gateway at the central tool-result chokepoint
|
|
37
|
-
* (`gateway.ts:onToolCall`) so the poke text piggybacks the next tool
|
|
38
|
-
* result back to claude. MCP doesn't allow mid-generation injection;
|
|
39
|
-
* tool results are the only synchronous moment we own the wire.
|
|
33
|
+
* The fallback message is enriched with in-flight tool context so it
|
|
34
|
+
* reads honestly ("running Grep \"foo\" for 4m") instead of a generic
|
|
35
|
+
* "still working…" when the agent is clearly busy — #1292. Tool churn
|
|
36
|
+
* enriches the TEXT only, never the timing.
|
|
40
37
|
*
|
|
41
38
|
* Kill switch: SWITCHROOM_DISABLE_SILENCE_POKE=1 disables the whole
|
|
42
|
-
* subsystem (no
|
|
43
|
-
* pacing prompt still
|
|
39
|
+
* subsystem (no timer, no fallback, no unwedge). The conversational
|
|
40
|
+
* pacing prompt + draft still apply; only the framework safety net is off.
|
|
44
41
|
*/
|
|
45
42
|
|
|
46
|
-
export type PokeLevel = 'ack' | 'soft' | 'firm'
|
|
47
|
-
|
|
48
43
|
/** #1292: snapshot of an in-flight tool call, surfaced in the 300s
|
|
49
44
|
* framework-fallback message so the user sees the actual observable
|
|
50
45
|
* ("running Grep \"foo\" for 4m") instead of the dishonest generic
|
|
@@ -66,29 +61,10 @@ export interface SilencePokeState {
|
|
|
66
61
|
turnStartedAt: number
|
|
67
62
|
/** Wall-clock ms of last outbound message, or null. */
|
|
68
63
|
lastOutboundAt: number | null
|
|
69
|
-
/** 0 = none, 1 = soft fired, 2 = firm fired, 3 = fallback fired. */
|
|
70
|
-
pokesFired: 0 | 1 | 2 | 3
|
|
71
|
-
/** Armed pending drain on the next tool result, or null. */
|
|
72
|
-
pokeArmed: { level: PokeLevel } | null
|
|
73
|
-
/** When true, soft threshold extends to subagentSoft (default 300s). */
|
|
74
|
-
subagentDispatchActive: boolean
|
|
75
64
|
/** Wall-clock ms of last `thinking` session event, or null. */
|
|
76
65
|
lastThinkingAt: number | null
|
|
77
66
|
/** True once the 300s framework fallback has fired this turn. */
|
|
78
67
|
fallbackFired: boolean
|
|
79
|
-
/** True once the early ack-budget poke has fired this turn. One-shot:
|
|
80
|
-
* the ack nudge is specifically about the *first* outbound, so it
|
|
81
|
-
* never re-arms even after the model later goes quiet again. */
|
|
82
|
-
ackPokeFired: boolean
|
|
83
|
-
/** True once the early awareness-ping has fired this turn. One-shot:
|
|
84
|
-
* the user only needs one "we know it's slow" cue before the heavier
|
|
85
|
-
* 300s fallback escalates. Independent of the ack-poke (which targets
|
|
86
|
-
* the model via piggyback) — awareness-ping targets the user directly
|
|
87
|
-
* via Telegram, so it lands even during pure-thinking or held-inbound
|
|
88
|
-
* silences when no tool_result is available to piggyback on. */
|
|
89
|
-
awarenessPingFired: boolean
|
|
90
|
-
/** Wall-clock ms of last poke fire — used for poke-success latency. */
|
|
91
|
-
lastPokeFiredAt: number | null
|
|
92
68
|
/** #1292: in-flight tool calls keyed by toolUseId. Populated by
|
|
93
69
|
* `noteToolStart` on every parent-agent `tool_use` event the gateway
|
|
94
70
|
* sees and drained by `noteToolEnd` on the matching `tool_result`.
|
|
@@ -102,37 +78,13 @@ export interface SilencePokeState {
|
|
|
102
78
|
}
|
|
103
79
|
|
|
104
80
|
export interface ThresholdsMs {
|
|
105
|
-
/**
|
|
106
|
-
*
|
|
107
|
-
* human-baseline "acknowledge within a beat" — far tighter than the
|
|
108
|
-
* 75s `soft` threshold, which measures silence-since-last-outbound
|
|
109
|
-
* and is the wrong instrument for "you never said hello." */
|
|
110
|
-
ack: number
|
|
111
|
-
/** Awareness ping: if NO outbound has landed this many ms after turn
|
|
112
|
-
* start, send a framework-owned user-visible "still working…" status
|
|
113
|
-
* message directly via Telegram. Sits between the model-targeted ack
|
|
114
|
-
* poke (10s) and the 300s framework_fallback. Lands even during pure
|
|
115
|
-
* extended-thinking or held-inbound silences when no tool_result is
|
|
116
|
-
* available to piggyback the model-targeted pokes onto. One-shot per
|
|
117
|
-
* turn — the 300s fallback handles further escalation if still silent. */
|
|
118
|
-
awarenessPing: number
|
|
119
|
-
soft: number
|
|
120
|
-
firm: number
|
|
81
|
+
/** Silence (since last outbound, or turn start) after which the
|
|
82
|
+
* framework sends the user-visible fallback AND unwedges the turn. */
|
|
121
83
|
fallback: number
|
|
122
|
-
/** Soft threshold when subagentDispatchActive=true. */
|
|
123
|
-
subagentSoft: number
|
|
124
|
-
/** How long after a poke we still count an outbound as a "success." */
|
|
125
|
-
pokeSuccessWindowMs: number
|
|
126
84
|
}
|
|
127
85
|
|
|
128
86
|
export const DEFAULT_THRESHOLDS: ThresholdsMs = {
|
|
129
|
-
ack: 10_000,
|
|
130
|
-
awarenessPing: 60_000,
|
|
131
|
-
soft: 75_000,
|
|
132
|
-
firm: 180_000,
|
|
133
87
|
fallback: 300_000,
|
|
134
|
-
subagentSoft: 300_000,
|
|
135
|
-
pokeSuccessWindowMs: 15_000,
|
|
136
88
|
}
|
|
137
89
|
|
|
138
90
|
export const DEFAULT_POLL_INTERVAL_MS = 5_000
|
|
@@ -158,25 +110,13 @@ export interface FrameworkFallbackContext {
|
|
|
158
110
|
}
|
|
159
111
|
|
|
160
112
|
export type SilencePokeMetric =
|
|
161
|
-
| { kind: 'silence_poke_fired'; key: string; level: PokeLevel; silence_ms: number; subagent_wait: boolean }
|
|
162
|
-
| { kind: 'silence_poke_succeeded'; key: string; level: PokeLevel; latency_ms: number }
|
|
163
113
|
| { kind: 'silence_fallback_sent'; key: string; fallback_kind: 'working' | 'thinking'; silence_ms: number }
|
|
164
|
-
| { kind: 'awareness_ping_sent'; key: string; fallback_kind: 'working' | 'thinking'; silence_ms: number }
|
|
165
114
|
|
|
166
115
|
export interface SilencePokeDeps {
|
|
167
116
|
/** Called when the 300s fallback fires. Caller sends the user-visible
|
|
168
|
-
* message + ensures it pings the device
|
|
169
|
-
* into noteOutbound for this — it's a framework-sourced message,
|
|
170
|
-
* not a model-sourced one, and we want pokes to continue (well, no,
|
|
171
|
-
* fallbackFired ensures only one per turn anyway). */
|
|
117
|
+
* message + ensures it pings the device, then unwedges the turn. */
|
|
172
118
|
onFrameworkFallback: (ctx: FrameworkFallbackContext) => Promise<void> | void
|
|
173
|
-
/**
|
|
174
|
-
* a user-visible "still working…" message — silent (no device ping),
|
|
175
|
-
* framework-owned, one-shot per turn. Reuses the same context shape
|
|
176
|
-
* as onFrameworkFallback so the gateway can reuse `formatFrameworkFallbackText`
|
|
177
|
-
* and the in-flight-tool enrichment. */
|
|
178
|
-
onAwarenessPing: (ctx: FrameworkFallbackContext) => Promise<void> | void
|
|
179
|
-
/** Telemetry sink for poke events. */
|
|
119
|
+
/** Telemetry sink for the fallback event. */
|
|
180
120
|
emitMetric: (event: SilencePokeMetric) => void
|
|
181
121
|
/** Threshold overrides (tests). */
|
|
182
122
|
thresholdsMs?: ThresholdsMs
|
|
@@ -205,69 +145,24 @@ export function startTurn(key: string, now: number): void {
|
|
|
205
145
|
state.set(key, {
|
|
206
146
|
turnStartedAt: now,
|
|
207
147
|
lastOutboundAt: null,
|
|
208
|
-
pokesFired: 0,
|
|
209
|
-
pokeArmed: null,
|
|
210
|
-
subagentDispatchActive: false,
|
|
211
148
|
lastThinkingAt: null,
|
|
212
149
|
fallbackFired: false,
|
|
213
|
-
ackPokeFired: false,
|
|
214
|
-
awarenessPingFired: false,
|
|
215
|
-
lastPokeFiredAt: null,
|
|
216
150
|
inFlightTools: new Map(),
|
|
217
151
|
})
|
|
218
152
|
}
|
|
219
153
|
|
|
220
154
|
/**
|
|
221
155
|
* Record a fresh user-visible outbound message (reply or stream_reply
|
|
222
|
-
* first-emit). Resets the silence clock
|
|
223
|
-
*
|
|
156
|
+
* first-emit). Resets the silence clock so the 300s fallback is measured
|
|
157
|
+
* from the most recent thing the user actually saw.
|
|
224
158
|
*/
|
|
225
159
|
export function noteOutbound(key: string, now: number): void {
|
|
226
160
|
const s = state.get(key)
|
|
227
161
|
if (s == null) return
|
|
228
|
-
// Success measurement: if a poke fired within the success window and
|
|
229
|
-
// an outbound just landed, count it as a successful poke.
|
|
230
|
-
const thresholds = activeDeps?.thresholdsMs ?? DEFAULT_THRESHOLDS
|
|
231
|
-
if (
|
|
232
|
-
s.lastPokeFiredAt != null
|
|
233
|
-
&& (now - s.lastPokeFiredAt) <= thresholds.pokeSuccessWindowMs
|
|
234
|
-
&& activeDeps != null
|
|
235
|
-
&& s.pokesFired >= 1
|
|
236
|
-
&& s.pokesFired <= 2
|
|
237
|
-
) {
|
|
238
|
-
activeDeps.emitMetric({
|
|
239
|
-
kind: 'silence_poke_succeeded',
|
|
240
|
-
key,
|
|
241
|
-
level: s.pokesFired === 1 ? 'soft' : 'firm',
|
|
242
|
-
latency_ms: now - s.lastPokeFiredAt,
|
|
243
|
-
})
|
|
244
|
-
}
|
|
245
162
|
s.lastOutboundAt = now
|
|
246
|
-
s.pokesFired = 0
|
|
247
|
-
s.pokeArmed = null
|
|
248
|
-
// Intentionally DO NOT clear `subagentDispatchActive` here. The
|
|
249
|
-
// model's `reply` narrating the dispatch ("spinning up @reviewer")
|
|
250
|
-
// is itself the outbound that resets the silence clock — clearing
|
|
251
|
-
// the flag would defeat the extended-threshold guarantee for the
|
|
252
|
-
// wait that follows. The flag persists until endTurn(). Fixes the
|
|
253
|
-
// non-blocking note from PR2 review (#1125).
|
|
254
|
-
s.lastPokeFiredAt = null
|
|
255
163
|
s.fallbackFired = false
|
|
256
164
|
}
|
|
257
165
|
|
|
258
|
-
/**
|
|
259
|
-
* Note that the model dispatched a sub-agent (Task tool, @worker, etc).
|
|
260
|
-
* Extends the soft threshold for THIS turn. The flag persists until
|
|
261
|
-
* endTurn() — subsequent outbound messages within the turn keep the
|
|
262
|
-
* extended threshold, which is the correct shape for the dispatch
|
|
263
|
-
* narrate → wait → child-result → summarise sequence.
|
|
264
|
-
*/
|
|
265
|
-
export function noteSubagentDispatch(key: string): void {
|
|
266
|
-
const s = state.get(key)
|
|
267
|
-
if (s == null) return
|
|
268
|
-
s.subagentDispatchActive = true
|
|
269
|
-
}
|
|
270
|
-
|
|
271
166
|
/**
|
|
272
167
|
* Record a `thinking` session event. Used to pick "still thinking…" vs
|
|
273
168
|
* "still working…" wording for the 300s framework fallback.
|
|
@@ -344,70 +239,16 @@ export function noteToolLabel(
|
|
|
344
239
|
entry.label = label
|
|
345
240
|
}
|
|
346
241
|
|
|
347
|
-
/**
|
|
348
|
-
* Drain any armed poke for ANY active turn and return the system-reminder
|
|
349
|
-
* text to append. Returns null if nothing is armed.
|
|
350
|
-
*
|
|
351
|
-
* Called at the gateway's tool-result chokepoint; the appended reminder
|
|
352
|
-
* piggybacks the result back to claude. Drains the flag immediately so
|
|
353
|
-
* the next tool result doesn't double-inject.
|
|
354
|
-
*
|
|
355
|
-
* Iterates all keys because the tool result doesn't carry which turn it
|
|
356
|
-
* belongs to. In practice the gateway has ≤1 active turn at a time, but
|
|
357
|
-
* the code handles multi-turn correctly: each turn's poke text is
|
|
358
|
-
* appended once (and never appears in another turn's tool result, since
|
|
359
|
-
* we drain by mutating the matched state).
|
|
360
|
-
*/
|
|
361
|
-
export function consumeArmedPoke(): string | null {
|
|
362
|
-
for (const s of state.values()) {
|
|
363
|
-
if (s.pokeArmed != null) {
|
|
364
|
-
const level = s.pokeArmed.level
|
|
365
|
-
s.pokeArmed = null
|
|
366
|
-
return formatPokeText(level)
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
return null
|
|
370
|
-
}
|
|
371
|
-
|
|
372
242
|
/** End a turn — drop state. Idempotent. */
|
|
373
243
|
export function endTurn(key: string): void {
|
|
374
244
|
state.delete(key)
|
|
375
245
|
}
|
|
376
246
|
|
|
377
|
-
/** Verbatim poke text. Wording is load-bearing — see issue #1122 design. */
|
|
378
|
-
export function formatPokeText(level: PokeLevel): string {
|
|
379
|
-
if (level === 'ack') {
|
|
380
|
-
return (
|
|
381
|
-
"[silence-poke] You haven't sent the user anything yet this turn — "
|
|
382
|
-
+ 'they are looking at a silent chat. Send a short, human one-line '
|
|
383
|
-
+ 'acknowledgement now via `reply` (e.g. "on it — checking"), in your '
|
|
384
|
-
+ "persona's voice, before you do any more work. A good colleague "
|
|
385
|
-
+ "answers in a beat; don't leave the message hanging while you think. "
|
|
386
|
-
+ 'If the full answer is genuinely seconds away, send that instead.'
|
|
387
|
-
)
|
|
388
|
-
}
|
|
389
|
-
if (level === 'soft') {
|
|
390
|
-
return (
|
|
391
|
-
"[silence-poke] You've been silent to the user for 75s. If you're "
|
|
392
|
-
+ "still working on this, send one short conversational reply — e.g. "
|
|
393
|
-
+ "\"still going, working through X\" — so they know you're alive. "
|
|
394
|
-
+ "Keep it brief; don't restate the task. If you're about to finish "
|
|
395
|
-
+ 'within the next few seconds, skip the update.'
|
|
396
|
-
)
|
|
397
|
-
}
|
|
398
|
-
return (
|
|
399
|
-
"[silence-poke] 3 minutes silent. Please send an update now — what "
|
|
400
|
-
+ "you're working on, or whether you're stuck. If something is taking "
|
|
401
|
-
+ 'unusually long (slow tool, network, waiting on a sub-agent), say so '
|
|
402
|
-
+ 'explicitly.'
|
|
403
|
-
)
|
|
404
|
-
}
|
|
405
|
-
|
|
406
247
|
/**
|
|
407
248
|
* Verbatim framework-fallback text — the user-visible "still working / still
|
|
408
249
|
* thinking" message the gateway sends at the 300s threshold when the model
|
|
409
250
|
* hasn't broken its own silence. Wording is load-bearing (see
|
|
410
|
-
* `reference/conversational-pacing.md` §
|
|
251
|
+
* `reference/conversational-pacing.md` § Safety net). Two principles:
|
|
411
252
|
*
|
|
412
253
|
* 1. The parenthetical `(no update from agent in N min)` is honest —
|
|
413
254
|
* distinguishes from "the agent said something" so users learn to trust
|
|
@@ -485,8 +326,10 @@ function truncateLabel(label: string): string {
|
|
|
485
326
|
}
|
|
486
327
|
|
|
487
328
|
/**
|
|
488
|
-
* Internal tick — iterates active states
|
|
489
|
-
*
|
|
329
|
+
* Internal tick — iterates active states and fires the 300s framework
|
|
330
|
+
* fallback (which the gateway turns into a user-visible message + an
|
|
331
|
+
* unwedge). Exported as __tickForTests so the suite can step the clock
|
|
332
|
+
* deterministically.
|
|
490
333
|
*/
|
|
491
334
|
function tick(now: number): void {
|
|
492
335
|
if (activeDeps == null) return
|
|
@@ -495,125 +338,9 @@ function tick(now: number): void {
|
|
|
495
338
|
const zeroAt = s.lastOutboundAt ?? s.turnStartedAt
|
|
496
339
|
const silence = now - zeroAt
|
|
497
340
|
if (silence < 0) continue
|
|
498
|
-
const softThreshold = s.subagentDispatchActive
|
|
499
|
-
? thresholds.subagentSoft
|
|
500
|
-
: thresholds.soft
|
|
501
|
-
|
|
502
|
-
// Ack budget — the framework enforcing the human-baseline "answer
|
|
503
|
-
// in a beat." Fires once, only when NOTHING has been sent this turn
|
|
504
|
-
// (`lastOutboundAt == null`), well before the 75s `soft` threshold.
|
|
505
|
-
// `soft` measures silence-since-last-outbound and is the wrong
|
|
506
|
-
// instrument for "you never acknowledged me." Independent of the
|
|
507
|
-
// soft/firm/fallback ladder: if the model never acks, it still
|
|
508
|
-
// escalates soft → firm → fallback on schedule after this.
|
|
509
|
-
if (
|
|
510
|
-
!s.ackPokeFired
|
|
511
|
-
&& s.lastOutboundAt == null
|
|
512
|
-
&& s.pokesFired === 0
|
|
513
|
-
&& silence >= thresholds.ack
|
|
514
|
-
) {
|
|
515
|
-
s.pokeArmed = { level: 'ack' }
|
|
516
|
-
s.ackPokeFired = true
|
|
517
|
-
s.lastPokeFiredAt = now
|
|
518
|
-
activeDeps.emitMetric({
|
|
519
|
-
kind: 'silence_poke_fired',
|
|
520
|
-
key,
|
|
521
|
-
level: 'ack',
|
|
522
|
-
silence_ms: silence,
|
|
523
|
-
subagent_wait: s.subagentDispatchActive,
|
|
524
|
-
})
|
|
525
|
-
continue
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// Awareness ping — framework-owned user-visible status BEFORE the
|
|
529
|
-
// 300s heavy fallback. Lands at ~60s even when the model is in pure
|
|
530
|
-
// extended-thinking or the inbound is held (#1892 follow-up), since
|
|
531
|
-
// it's delivered directly via the gateway → Telegram, not
|
|
532
|
-
// piggybacked on tool_result. One-shot per turn; suppressed if any
|
|
533
|
-
// outbound has happened. The 300s fallback is unchanged and
|
|
534
|
-
// escalates further if silence persists.
|
|
535
|
-
//
|
|
536
|
-
// Independent of pokesFired so soft/firm/fallback still escalate on
|
|
537
|
-
// their own schedule. Independent of ackPokeFired so a long-running
|
|
538
|
-
// turn that already received the ack-poke (then went silent again)
|
|
539
|
-
// still gets the user-facing awareness ping.
|
|
540
|
-
if (
|
|
541
|
-
!s.awarenessPingFired
|
|
542
|
-
&& s.lastOutboundAt == null
|
|
543
|
-
&& !s.subagentDispatchActive
|
|
544
|
-
&& silence >= thresholds.awarenessPing
|
|
545
|
-
) {
|
|
546
|
-
s.awarenessPingFired = true
|
|
547
|
-
const { chatId, threadId } = parseKey(key)
|
|
548
|
-
const recentThinking = s.lastThinkingAt != null
|
|
549
|
-
&& (now - s.lastThinkingAt) < 30_000
|
|
550
|
-
const fallbackKind: 'working' | 'thinking' = recentThinking ? 'thinking' : 'working'
|
|
551
|
-
const inFlightTools: ToolSnapshot[] = Array.from(s.inFlightTools.values())
|
|
552
|
-
.sort((a, b) => a.startedAt - b.startedAt)
|
|
553
|
-
.map(t => ({
|
|
554
|
-
name: t.name,
|
|
555
|
-
label: t.label,
|
|
556
|
-
durationMs: now - t.startedAt,
|
|
557
|
-
}))
|
|
558
|
-
activeDeps.emitMetric({
|
|
559
|
-
kind: 'awareness_ping_sent',
|
|
560
|
-
key,
|
|
561
|
-
fallback_kind: fallbackKind,
|
|
562
|
-
silence_ms: silence,
|
|
563
|
-
})
|
|
564
|
-
try {
|
|
565
|
-
const ret = activeDeps.onAwarenessPing({
|
|
566
|
-
key,
|
|
567
|
-
chatId,
|
|
568
|
-
threadId,
|
|
569
|
-
fallbackKind,
|
|
570
|
-
silenceMs: silence,
|
|
571
|
-
inFlightTools,
|
|
572
|
-
})
|
|
573
|
-
if (ret != null && typeof (ret as Promise<unknown>).then === 'function') {
|
|
574
|
-
;(ret as Promise<unknown>).catch(err => {
|
|
575
|
-
process.stderr.write(`silence-poke: awareness-ping handler rejected: ${err}\n`)
|
|
576
|
-
})
|
|
577
|
-
}
|
|
578
|
-
} catch (err) {
|
|
579
|
-
process.stderr.write(`silence-poke: awareness-ping handler threw: ${err}\n`)
|
|
580
|
-
}
|
|
581
|
-
// Don't `continue` — soft/firm/fallback can still arm in the same tick
|
|
582
|
-
// if their thresholds have also been crossed. Awareness-ping is a
|
|
583
|
-
// sibling signal, not part of the ladder.
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
if (s.pokesFired === 0 && silence >= softThreshold) {
|
|
587
|
-
s.pokeArmed = { level: 'soft' }
|
|
588
|
-
s.pokesFired = 1
|
|
589
|
-
s.lastPokeFiredAt = now
|
|
590
|
-
activeDeps.emitMetric({
|
|
591
|
-
kind: 'silence_poke_fired',
|
|
592
|
-
key,
|
|
593
|
-
level: 'soft',
|
|
594
|
-
silence_ms: silence,
|
|
595
|
-
subagent_wait: s.subagentDispatchActive,
|
|
596
|
-
})
|
|
597
|
-
continue
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
if (s.pokesFired === 1 && silence >= thresholds.firm) {
|
|
601
|
-
s.pokeArmed = { level: 'firm' }
|
|
602
|
-
s.pokesFired = 2
|
|
603
|
-
s.lastPokeFiredAt = now
|
|
604
|
-
activeDeps.emitMetric({
|
|
605
|
-
kind: 'silence_poke_fired',
|
|
606
|
-
key,
|
|
607
|
-
level: 'firm',
|
|
608
|
-
silence_ms: silence,
|
|
609
|
-
subagent_wait: s.subagentDispatchActive,
|
|
610
|
-
})
|
|
611
|
-
continue
|
|
612
|
-
}
|
|
613
341
|
|
|
614
|
-
if (
|
|
342
|
+
if (!s.fallbackFired && silence >= thresholds.fallback) {
|
|
615
343
|
s.fallbackFired = true
|
|
616
|
-
s.pokesFired = 3
|
|
617
344
|
const { chatId, threadId } = parseKey(key)
|
|
618
345
|
const recentThinking = s.lastThinkingAt != null
|
|
619
346
|
&& (now - s.lastThinkingAt) < 30_000
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pin the contract: the boot card silences its Telegram
|
|
3
|
-
* (passes `disable_notification: true` to `sendMessage`)
|
|
4
|
-
*
|
|
2
|
+
* Pin the contract: the boot card ALWAYS silences its Telegram
|
|
3
|
+
* notification (passes `disable_notification: true` to `sendMessage`),
|
|
4
|
+
* regardless of what triggered the restart.
|
|
5
5
|
*
|
|
6
6
|
* Background: every agent in the fleet posts a boot card after a
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* went wrong.
|
|
7
|
+
* restart. A boot card is a status RECORD ("✅ <agent> back up ·
|
|
8
|
+
* vX.Y.Z") that lands in the chat for scroll-back — it is never
|
|
9
|
+
* something that should buzz a phone. A fleet redeploy of N agents
|
|
10
|
+
* would otherwise produce N push notifications; even a single user
|
|
11
|
+
* `/restart` or a crash-recovery is a record, not an alert.
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
13
|
+
* History: this used to be keyed on the `operator:` reason-detail
|
|
14
|
+
* prefix — only routine `switchroom update` was silent, while user
|
|
15
|
+
* `/restart`, `cli: switchroom restart` rollouts, crashes, and fresh
|
|
16
|
+
* boots all still notified. Operator decision (2026-05-29): silence
|
|
17
|
+
* them all, unconditionally. These cases now assert the inverse of
|
|
18
|
+
* what they originally pinned, across every reason path, so a future
|
|
19
|
+
* regression that re-introduces a notifying boot card is caught.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
import { describe, it, expect } from 'vitest'
|
|
@@ -54,50 +55,46 @@ function mkOpts(overrides: { restartReasonDetail?: string; restartReason?: 'plan
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
describe('boot card — silent
|
|
58
|
-
it('
|
|
58
|
+
describe('boot card — always silent (no Telegram notification)', () => {
|
|
59
|
+
it('silences operator-initiated redeploys (operator: switchroom update)', async () => {
|
|
59
60
|
const { bot, sends } = makeCapturingBot()
|
|
60
61
|
await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'operator: switchroom update' }))
|
|
61
62
|
expect(sends).toHaveLength(1)
|
|
62
63
|
expect(sends[0]!.opts.disable_notification).toBe(true)
|
|
63
64
|
})
|
|
64
65
|
|
|
65
|
-
it('
|
|
66
|
+
it('silences user-initiated restarts (user: /restart from chat)', async () => {
|
|
66
67
|
const { bot, sends } = makeCapturingBot()
|
|
67
68
|
await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'user: /restart from chat' }))
|
|
68
69
|
expect(sends).toHaveLength(1)
|
|
69
|
-
expect(sends[0]!.opts.disable_notification).
|
|
70
|
+
expect(sends[0]!.opts.disable_notification).toBe(true)
|
|
70
71
|
})
|
|
71
72
|
|
|
72
|
-
it('
|
|
73
|
+
it('silences cli rollouts (cli: switchroom restart)', async () => {
|
|
73
74
|
const { bot, sends } = makeCapturingBot()
|
|
74
75
|
await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'cli: switchroom restart' }))
|
|
75
76
|
expect(sends).toHaveLength(1)
|
|
76
|
-
expect(sends[0]!.opts.disable_notification).
|
|
77
|
+
expect(sends[0]!.opts.disable_notification).toBe(true)
|
|
77
78
|
})
|
|
78
79
|
|
|
79
|
-
it('
|
|
80
|
+
it('silences crash / fresh boots (restartReasonDetail undefined)', async () => {
|
|
80
81
|
const { bot, sends } = makeCapturingBot()
|
|
81
82
|
await startBootCard('chat1', undefined, bot, mkOpts({ restartReason: 'crash' }))
|
|
82
83
|
expect(sends).toHaveLength(1)
|
|
83
|
-
expect(sends[0]!.opts.disable_notification).
|
|
84
|
+
expect(sends[0]!.opts.disable_notification).toBe(true)
|
|
84
85
|
})
|
|
85
86
|
|
|
86
|
-
it('
|
|
87
|
+
it('silences when restartReasonDetail is empty string', async () => {
|
|
87
88
|
const { bot, sends } = makeCapturingBot()
|
|
88
89
|
await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: '' }))
|
|
89
90
|
expect(sends).toHaveLength(1)
|
|
90
|
-
expect(sends[0]!.opts.disable_notification).
|
|
91
|
+
expect(sends[0]!.opts.disable_notification).toBe(true)
|
|
91
92
|
})
|
|
92
93
|
|
|
93
|
-
it('
|
|
94
|
-
// Defence against future operator-side reasons that don't actually
|
|
95
|
-
// want silent — confirms we're matching the prefix-with-colon shape,
|
|
96
|
-
// not a fuzzy contains.
|
|
94
|
+
it('silences any other reason shape too (no notifying path remains)', async () => {
|
|
97
95
|
const { bot, sends } = makeCapturingBot()
|
|
98
96
|
await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'operator-ish: rolled over' }))
|
|
99
97
|
expect(sends).toHaveLength(1)
|
|
100
|
-
|
|
101
|
-
expect(sends[0]!.opts.disable_notification).toBeUndefined()
|
|
98
|
+
expect(sends[0]!.opts.disable_notification).toBe(true)
|
|
102
99
|
})
|
|
103
100
|
})
|