switchroom 0.19.17 → 0.19.19
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/run-hook.sh +148 -0
- package/bin/workspace-dynamic-hook.sh +147 -38
- package/dist/agent-scheduler/index.js +13 -4
- package/dist/auth-broker/index.js +32 -5
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +13 -4
- package/dist/cli/switchroom.js +10614 -8104
- package/dist/host-control/main.js +12849 -11446
- package/dist/vault/approvals/kernel-server.js +90 -12
- package/dist/vault/broker/server.js +277 -94
- package/package.json +5 -3
- package/profiles/_base/start.sh.hbs +69 -5
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +3 -3
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +8 -6
- package/telegram-plugin/bridge/bridge.ts +25 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +28 -20
- package/telegram-plugin/dist/gateway/gateway.js +2077 -1087
- package/telegram-plugin/dist/server.js +32 -20
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-card.ts +5 -1
- package/telegram-plugin/gateway/boot-probes.ts +113 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
- package/telegram-plugin/gateway/gateway.ts +43 -1
- package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
- package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
- package/telegram-plugin/gateway/ipc-server.ts +13 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/registry/turns-schema.test.ts +8 -3
- package/telegram-plugin/registry/turns-schema.ts +40 -12
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/silence-poke.ts +138 -0
- package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
- package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
- package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
- package/telegram-plugin/tests/registry-turns.test.ts +13 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
- package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
- package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
- package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/telegram-plugin/worker-activity-feed.ts +51 -1
- package/vendor/hindsight-memory/CHANGELOG.md +73 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
- package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
- package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
- package/vendor/hindsight-memory/scripts/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
* `typing…` indicator and no activity card. The user — who dispatched the work
|
|
11
11
|
* and is waiting — sees nothing until the turn is well underway. Worse, a
|
|
12
12
|
* handback turn historically never even got a turn-long typing loop
|
|
13
|
-
* (`startTurnTypingLoop`
|
|
14
|
-
* dark stretch extended past turn start.
|
|
13
|
+
* (`startTurnTypingLoop` had a single caller on the real-inbound path), so the
|
|
14
|
+
* dark stretch extended past turn start. That second half is now closed
|
|
15
|
+
* independently of this seam: the `enqueue` seam arms the loop for EVERY minted
|
|
16
|
+
* turn (#3544), so an un-adopted handback turn is lit for its whole compose
|
|
17
|
+
* window. This seam still covers the pre-turn stretch (release → enqueue).
|
|
15
18
|
*
|
|
16
19
|
* THE FIX (one continuous card lifecycle, NOT an orphan). The moment the
|
|
17
20
|
* buffered handback is RELEASED, emit a pre-turn signal for its topic:
|
|
@@ -63,6 +66,10 @@
|
|
|
63
66
|
* gateway's mid-session + boot reapers are the crash backstop (the complete
|
|
64
67
|
* record lets them finalize an orphan across a restart), and a reap hook
|
|
65
68
|
* lets them stop the typing loop + drop the map entry too.
|
|
69
|
+
* The typing stop on EITHER reap path is SKIPPED when a real turn is live
|
|
70
|
+
* on that key (`hasLiveTurn`, #3544): the loop is keyed on (chat, thread),
|
|
71
|
+
* so reaping a stale pre-turn entry must never darken a turn that is still
|
|
72
|
+
* composing — that turn's canonical turn-end owns the stop.
|
|
66
73
|
*
|
|
67
74
|
* • DEBOUNCE (~700ms) kills sub-second-worker flicker: a worker that finishes
|
|
68
75
|
* and whose turn mints within the debounce window never paints a pre-turn
|
|
@@ -155,6 +162,15 @@ export interface HandbackPreturnSignalDeps {
|
|
|
155
162
|
* entirely (design lever 5: never paint beneath a settled turn). Optional;
|
|
156
163
|
* defaults to "not settled". */
|
|
157
164
|
isTurnSettled?: (statusKey: string) => boolean
|
|
165
|
+
/** True IFF a turn is CURRENTLY live (minted, not yet ended) on this topic
|
|
166
|
+
* key. Consulted before the orphan reap stops the typing loop: that loop is
|
|
167
|
+
* keyed on (chat, thread), NOT on this seam's entry, so stopping it while a
|
|
168
|
+
* real turn is composing darkens that turn's indicator for the rest of its
|
|
169
|
+
* life (#3544 — the 30 s reap killing a healthy loop). The stop is only
|
|
170
|
+
* ours to make when nothing else owns the key; when a turn IS live, its
|
|
171
|
+
* canonical turn-end owns the stop. Optional; defaults to "no live turn"
|
|
172
|
+
* (the pre-#3544 behaviour). */
|
|
173
|
+
hasLiveTurn?: (statusKey: string) => boolean
|
|
158
174
|
now?: () => number
|
|
159
175
|
/** Debounce before painting the pre-turn card (kills sub-second flicker). */
|
|
160
176
|
debounceMs?: number
|
|
@@ -312,12 +328,30 @@ export function createHandbackPreturnSignal(
|
|
|
312
328
|
})
|
|
313
329
|
}
|
|
314
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Stop the typing loop for an entry ONLY when no real turn owns that key
|
|
333
|
+
* (#3544). The loop is keyed on (chat, thread); a live turn's indicator would
|
|
334
|
+
* otherwise be killed mid-compose by this seam's age-based reap, and nothing
|
|
335
|
+
* re-arms it. When a turn IS live the canonical turn-end owns the stop, so
|
|
336
|
+
* skipping here cannot leak an interval.
|
|
337
|
+
*/
|
|
338
|
+
function stopTypingUnlessTurnLive(entry: PreTurnEntry, reason: string): void {
|
|
339
|
+
if (deps.hasLiveTurn?.(entry.statusKey) === true) {
|
|
340
|
+
log(
|
|
341
|
+
`handback-preturn-signal: ${reason} key=${entry.statusKey} ` +
|
|
342
|
+
`typing=kept (live turn owns the stop)\n`,
|
|
343
|
+
)
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
deps.stopTypingLoop(entry.chatId, entry.threadId)
|
|
347
|
+
}
|
|
348
|
+
|
|
315
349
|
function reap(entry: PreTurnEntry): void {
|
|
316
350
|
entry.reapTimer = null
|
|
317
351
|
if (entry.consumed) return
|
|
318
352
|
entry.consumed = true
|
|
319
353
|
// Stop the forever-running typing loop and finalize the frozen card.
|
|
320
|
-
|
|
354
|
+
stopTypingUnlessTurnLive(entry, 'orphan reap')
|
|
321
355
|
if (entry.activityMessageId != null) {
|
|
322
356
|
const record: PreTurnCardRecord = {
|
|
323
357
|
turnKey: entry.syntheticTurnKey,
|
|
@@ -347,11 +381,31 @@ export function createHandbackPreturnSignal(
|
|
|
347
381
|
if (chatId == null || chatId === '') return
|
|
348
382
|
const threadId = inbound.threadId ?? null
|
|
349
383
|
const adoptTurnId = deps.deriveTurnId(chatId, threadId, inbound.messageId)
|
|
350
|
-
|
|
351
|
-
|
|
384
|
+
// Observability (#3544): one line per handback RELEASE — the event that
|
|
385
|
+
// was previously invisible in the gateway log (zero `subagent_handback`
|
|
386
|
+
// lines were ever captured), so the next occurrence of a dark handback
|
|
387
|
+
// turn is diagnosable. Bounded by worker completions, not by turn volume.
|
|
388
|
+
const releaseKey = deps.chatKey(chatId, threadId)
|
|
389
|
+
if (adoptTurnId == null) {
|
|
390
|
+
log(`handback-preturn-signal: release key=${releaseKey} armed=no (no derivable turnId)\n`)
|
|
391
|
+
return // no stable identity → can't be adopted
|
|
392
|
+
}
|
|
393
|
+
const statusKey = releaseKey
|
|
352
394
|
// Dedupe: a live entry already covers this topic (e.g. two handbacks for
|
|
353
395
|
// the same topic released together — the first owns the pre-turn signal).
|
|
354
|
-
if (byKey.has(statusKey))
|
|
396
|
+
if (byKey.has(statusKey)) {
|
|
397
|
+
// NOT a dark turn any more: the enqueue seam arms the typing loop
|
|
398
|
+
// unconditionally for every minted turn (#3544), so a deduped handback
|
|
399
|
+
// still lights up — it just doesn't own the pre-turn card.
|
|
400
|
+
log(
|
|
401
|
+
`handback-preturn-signal: release key=${statusKey} turnId=${adoptTurnId} ` +
|
|
402
|
+
`armed=no (deduped: topic already has a live pre-turn entry)\n`,
|
|
403
|
+
)
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
log(
|
|
407
|
+
`handback-preturn-signal: release key=${statusKey} turnId=${adoptTurnId} armed=yes\n`,
|
|
408
|
+
)
|
|
355
409
|
const startedAt = now()
|
|
356
410
|
const syntheticTurnKey = `${PRETURN_TURNKEY_PREFIX}${statusKey}:${startedAt}`
|
|
357
411
|
const entry: PreTurnEntry = {
|
|
@@ -423,7 +477,7 @@ export function createHandbackPreturnSignal(
|
|
|
423
477
|
const entry = byKey.get(statusKey)
|
|
424
478
|
if (entry == null) return
|
|
425
479
|
entry.consumed = true
|
|
426
|
-
|
|
480
|
+
stopTypingUnlessTurnLive(entry, 'reaped record')
|
|
427
481
|
dropEntry(entry)
|
|
428
482
|
},
|
|
429
483
|
|
|
@@ -427,6 +427,11 @@ export interface RequestConfigApprovalMessage {
|
|
|
427
427
|
unifiedDiff: string;
|
|
428
428
|
/** Card timeout in milliseconds (gateway-enforced). */
|
|
429
429
|
timeoutMs: number;
|
|
430
|
+
/**
|
|
431
|
+
* Optional card header override (KEN-129 — update-check drift card).
|
|
432
|
+
* Absent → the default "🛠 Config edit proposed" header. ≤200 chars.
|
|
433
|
+
*/
|
|
434
|
+
title?: string;
|
|
430
435
|
}
|
|
431
436
|
|
|
432
437
|
/**
|
|
@@ -391,6 +391,19 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
391
391
|
if (typeof m.timeoutMs !== "number"
|
|
392
392
|
|| !Number.isFinite(m.timeoutMs)
|
|
393
393
|
|| (m.timeoutMs as number) <= 0) return false;
|
|
394
|
+
// Optional header override (KEN-129) — absent falls back to the
|
|
395
|
+
// default config-edit header. SINGLE LINE ONLY: the header renders
|
|
396
|
+
// VERBATIM as the card's first line (it carries intentional markdown,
|
|
397
|
+
// so it can't be escaped), which means a newline would let a caller
|
|
398
|
+
// forge the `Agent:` / `Reason:` lines beneath it — or unbalance the
|
|
399
|
+
// diff's ``` fence — on a card the operator is about to approve.
|
|
400
|
+
// Control characters are rejected for the same reason.
|
|
401
|
+
if (m.title !== undefined
|
|
402
|
+
&& (typeof m.title !== "string"
|
|
403
|
+
|| (m.title as string).length === 0
|
|
404
|
+
|| (m.title as string).length > 200
|
|
405
|
+
// eslint-disable-next-line no-control-regex
|
|
406
|
+
|| /[\u0000-\u001f\u007f]/.test(m.title as string))) return false;
|
|
394
407
|
return true;
|
|
395
408
|
}
|
|
396
409
|
case "request_config_finalize": {
|
|
@@ -38,10 +38,12 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
38
38
|
SILENCE_FALLBACK_HARD_MS,
|
|
39
39
|
SILENCE_FLOOR_MS,
|
|
40
40
|
SILENCE_DEFER_INFLIGHT_TOOLS,
|
|
41
|
+
isObligationOpenForTurn,
|
|
41
42
|
TURN_PREVIEW_MAX,
|
|
42
43
|
STATE_DIR,
|
|
43
44
|
isLegitimatelyWorking,
|
|
44
45
|
getCurrentTurn,
|
|
46
|
+
getCurrentTurnForKey,
|
|
45
47
|
getInFlightUpdate,
|
|
46
48
|
getTurnsDb,
|
|
47
49
|
getInboundSpool,
|
|
@@ -68,6 +70,23 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
68
70
|
thresholdsMs: { fallback: SILENCE_FALLBACK_MS, fallbackHardCeiling: SILENCE_FALLBACK_HARD_MS, floor: SILENCE_FLOOR_MS },
|
|
69
71
|
deferFallbackWhileToolInFlight: SILENCE_DEFER_INFLIGHT_TOOLS,
|
|
70
72
|
isLegitimatelyWorking: (key) => isLegitimatelyWorking(key),
|
|
73
|
+
// #3552 — orphan-state reaper predicate. Deliberately the SAME condition the
|
|
74
|
+
// `onFrameworkFallback` late-fire guard below uses: no `activeTurnStartedAt`
|
|
75
|
+
// entry AND no current turn ⇒ the turn this state belongs to is over. Note
|
|
76
|
+
// `activeTurnStartedAt` alone is NOT sufficient — `releaseTurnBufferGate`
|
|
77
|
+
// clears it mid-turn on every final-answer reply while the turn keeps running
|
|
78
|
+
// (post-answer housekeeping), and silence-poke must keep watching that turn.
|
|
79
|
+
//
|
|
80
|
+
// #3580 — the second clause is read KEYED (`getCurrentTurnForKey(key)`, i.e.
|
|
81
|
+
// `currentTurnMap.get(key)`), never the bare `getCurrentTurn()` singleton.
|
|
82
|
+
// Under `SWITCHROOM_EMISSION_AUTHORITY=1` that singleton is a MOST-RECENT-SET
|
|
83
|
+
// MIRROR, so an unkeyed read answers about whichever topic started a turn last
|
|
84
|
+
// — not about `key`. Combined with the buffer-gate release above, topic A
|
|
85
|
+
// (live, gate cleared mid-turn) read DEAD as soon as topic B started and ended
|
|
86
|
+
// a turn, and the tick false-reaped A: permanent and silent, nothing re-arms
|
|
87
|
+
// until the next `startTurn`, so A loses both its mid-turn beat and its 300 s
|
|
88
|
+
// #1122 unwedge. Flag-OFF the keyed read IS the singleton, byte-for-byte.
|
|
89
|
+
isTurnLive: (key) => !(activeTurnStartedAt.get(key) == null && getCurrentTurnForKey(key) == null),
|
|
71
90
|
emitMetric: (event) => {
|
|
72
91
|
// Re-emit through the unified runtime-metrics fan-out (PostHog + JSONL).
|
|
73
92
|
emitRuntimeMetric(event)
|
|
@@ -96,8 +115,10 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
96
115
|
// retired in #2667.
|
|
97
116
|
onMidTurnFloor: async (ctx) => {
|
|
98
117
|
// Late-fire guard, mirroring the fallback: a clean turn-end can race the
|
|
99
|
-
// tick. If the turn is gone, stay silent.
|
|
100
|
-
|
|
118
|
+
// tick. If the turn is gone, stay silent. #3580 — keyed read; an unkeyed
|
|
119
|
+
// `getCurrentTurn()` here answers about another topic's turn (see the
|
|
120
|
+
// `isTurnLive` note above) and silences a live turn's approval re-ping.
|
|
121
|
+
if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurnForKey(ctx.key) == null) return
|
|
101
122
|
const blockedOnApproval = activeStatusReactions
|
|
102
123
|
.get(statusKey(ctx.chatId, ctx.threadId))
|
|
103
124
|
?.isAwaiting() ?? false
|
|
@@ -142,7 +163,18 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
142
163
|
// events (124 of 138 `currentTurn_nulled=false` cases). Distinct
|
|
143
164
|
// log line so observability still tracks the fact that the silence
|
|
144
165
|
// crossed threshold; the wedge counter is no longer polluted.
|
|
145
|
-
|
|
166
|
+
//
|
|
167
|
+
// #3552: with the `isTurnLive` reaper wired above, this branch is now only
|
|
168
|
+
// the sub-poll-interval race (the turn ends between the tick's reap check
|
|
169
|
+
// and this handler running). The steady-state case it used to absorb —
|
|
170
|
+
// state armed for the full 300s against a turn that ended minutes earlier,
|
|
171
|
+
// 504 events in 14 days vs 110 real fires — is reaped at the tick instead,
|
|
172
|
+
// so this counter finally reads as the genuine race it names.
|
|
173
|
+
//
|
|
174
|
+
// #3580 — keyed read, same reason as `isTurnLive`: unkeyed, a sibling
|
|
175
|
+
// topic's turn flip made this guard skip the 300 s unwedge for a genuinely
|
|
176
|
+
// wedged turn (the #1122 permanent-wedge class) and log it as a clean race.
|
|
177
|
+
if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurnForKey(ctx.key) == null) {
|
|
146
178
|
process.stderr.write(
|
|
147
179
|
`telegram gateway: silence-poke framework-fallback late-fire skipped — ` +
|
|
148
180
|
`turn ended cleanly during silence window ` +
|
|
@@ -259,6 +291,21 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
259
291
|
// stays null and we skip the send. The turn-teardown below (the unwedge —
|
|
260
292
|
// the one job the live draft can't do, per the conversational-pacing RFC)
|
|
261
293
|
// still runs unconditionally.
|
|
294
|
+
// #3551 L1/L2 — the teardown-notice suppression signal. It must be narrower
|
|
295
|
+
// than "some text went out", and it must mean DELIVERED.
|
|
296
|
+
//
|
|
297
|
+
// L1: `text` has two sources with opposite meanings. The approval re-ping is
|
|
298
|
+
// a LOUD, user-addressed message that explains why nothing is happening and
|
|
299
|
+
// asks the user to act — stacking a teardown notice on it is noise. The
|
|
300
|
+
// deterministic `formatUpdateStatusLine` is a SILENT status surface about an
|
|
301
|
+
// unrelated in-flight `update_apply`; it says nothing about this turn ending.
|
|
302
|
+
// A user who asked a question during an update and got killed anyway is
|
|
303
|
+
// EXACTLY the #3551 case, so that line must not suppress the notice.
|
|
304
|
+
//
|
|
305
|
+
// L2: computed from send SUCCESS, not from `text != null`. The send sits in a
|
|
306
|
+
// try/catch that only logs, so a throwing approval re-ping would otherwise
|
|
307
|
+
// suppress the notice while the user received nothing at all.
|
|
308
|
+
let userAddressedTextDelivered = false
|
|
262
309
|
if (text != null) {
|
|
263
310
|
try {
|
|
264
311
|
// Conditional: when the turn is parked on an approval card, this
|
|
@@ -268,6 +315,7 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
268
315
|
// line stays SILENT (a status surface). Gate on `blockedOnApproval`.
|
|
269
316
|
// The send stays in gateway.ts behind the bot-api retry policy.
|
|
270
317
|
await sendSilenceText(ctx.chatId, ctx.threadId ?? null, text, blockedOnApproval ? false : true)
|
|
318
|
+
userAddressedTextDelivered = blockedOnApproval
|
|
271
319
|
} catch (err) {
|
|
272
320
|
process.stderr.write(
|
|
273
321
|
`silence-poke fallback sendMessage failed chat=${ctx.chatId} thread=${ctx.threadId}: ${err}\n`,
|
|
@@ -299,6 +347,18 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
299
347
|
wedgedTurn != null &&
|
|
300
348
|
wedgedTurn.sessionChatId === fbChatId &&
|
|
301
349
|
wedgedTurn.sessionThreadId === fbThreadId
|
|
350
|
+
// #3580 L3 — the ONE liveness decision this fire makes about the wedged
|
|
351
|
+
// turn. It gates the teardown below AND is what `decideFallbackTeardownNotice`
|
|
352
|
+
// is told as `tearsDownLiveTurn`, so the notice can never claim a teardown
|
|
353
|
+
// the gate skipped (previously the notice got the looser bare
|
|
354
|
+
// `turnMatchesFallback`, and a user could be told "the framework ended that
|
|
355
|
+
// stalled turn" about a turn that never ended). Evaluated HERE — before the
|
|
356
|
+
// `purgeChatStale` self-heal and before `endCurrentTurnForKey`, both of
|
|
357
|
+
// which drop the very entry `turnLiveForItsTopic` reads — so it answers
|
|
358
|
+
// "was this turn live when the fallback fired", the question both the
|
|
359
|
+
// teardown and the notice actually mean.
|
|
360
|
+
const tearsDownLiveTurn =
|
|
361
|
+
turnMatchesFallback && wedgedTurn != null && turnLiveForItsTopic(wedgedTurn)
|
|
302
362
|
const turnStartedAt = activeTurnStartedAt.get(fbKey)
|
|
303
363
|
if (turnStartedAt != null) {
|
|
304
364
|
const turnDurationMs = Date.now() - turnStartedAt
|
|
@@ -424,7 +484,14 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
424
484
|
// Flag-ON: `byKey.get(fbKey) === wedgedTurn`, so the keyed delete still
|
|
425
485
|
// fires when the LIVE mirror has already flipped to another topic B (a bare
|
|
426
486
|
// `currentTurn === wedgedTurn` would falsely skip and leak A's byKey entry).
|
|
427
|
-
|
|
487
|
+
//
|
|
488
|
+
// #3580 L3 — the gate is the `tearsDownLiveTurn` const computed above, the
|
|
489
|
+
// SAME value handed to `decideFallbackTeardownNotice`. Do not re-inline the
|
|
490
|
+
// condition here: it must be evaluated before the `purgeChatStale` sweep
|
|
491
|
+
// above (which can drop this key's entry and so flip
|
|
492
|
+
// `turnLiveForItsTopic`), and the notice must speak for exactly what this
|
|
493
|
+
// gate did.
|
|
494
|
+
if (tearsDownLiveTurn && wedgedTurn != null) {
|
|
428
495
|
// Status-surface observability: emit the lifecycle CLEAR for the
|
|
429
496
|
// silence-poke teardown so a fallback-nulled turn has a turn-lifecycle
|
|
430
497
|
// line like every other clear path (the framework-fallback line below is
|
|
@@ -439,6 +506,56 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
439
506
|
// the mirror iff it still points here — a live sibling topic is untouched.
|
|
440
507
|
endCurrentTurnForKey(wedgedTurn, fbKey)
|
|
441
508
|
}
|
|
509
|
+
// #3551 — the teardown NOTICE. Everything above just ENDED a live turn. On
|
|
510
|
+
// the non-approval branch `text` is null (`formatFrameworkFallbackText`
|
|
511
|
+
// returns a string only when parked on an approval card), so before this
|
|
512
|
+
// the user saw literally nothing: N minutes of silence, then the agent
|
|
513
|
+
// apparently starting the same request over. Killing a user's turn with no
|
|
514
|
+
// user-visible signal is a correctness bug, not a tuning knob — 110 fires /
|
|
515
|
+
// 14 days, ~9h of dead air (#3551). The teardown itself is NOT removed (it
|
|
516
|
+
// is the #1122 unwedge, the fallback's one load-bearing job); it is made
|
|
517
|
+
// observable. Sent AFTER the teardown so the notice cannot itself be
|
|
518
|
+
// clobbered by the state purge, and gated by `decideFallbackTeardownNotice`
|
|
519
|
+
// so it can only ever speak for a human-waiting, undelivered turn that this
|
|
520
|
+
// fire actually killed — at most once, since `endTurn(fbKey)` above dropped
|
|
521
|
+
// the key.
|
|
522
|
+
const teardownNotice = silencePoke.decideFallbackTeardownNotice({
|
|
523
|
+
tearsDownLiveTurn,
|
|
524
|
+
role: wedgedTurn?.role ?? null,
|
|
525
|
+
finalAnswerDelivered: wedgedTurn?.finalAnswerDelivered ?? false,
|
|
526
|
+
userAddressedTextDelivered,
|
|
527
|
+
// #3575 review B1 — this must be a question about THIS turn, not a static
|
|
528
|
+
// env flag. `OBLIGATION_LEDGER_ENABLED` is true for the whole process, so
|
|
529
|
+
// it promised "being re-asked now" in cases where no re-ask can ever
|
|
530
|
+
// happen: the obligation already hit OBLIGATION_REPRESENT_MAX (2) and
|
|
531
|
+
// escalated+closed, or it was closed silently by an outbound-since-open
|
|
532
|
+
// (obligation-wiring.ts:230/:294), or the inbound never opened one at all
|
|
533
|
+
// (synthetic / steering / interrupt). The user was then told to wait for a
|
|
534
|
+
// re-ask that never comes. Ask the ledger about this turn's own origin id
|
|
535
|
+
// instead, so the honest "please re-send it" branch fires when there is no
|
|
536
|
+
// open obligation. The lookup is deliberately made HERE, after the
|
|
537
|
+
// teardown above (which does not touch the ledger), so it reflects the
|
|
538
|
+
// ledger state at the moment we speak.
|
|
539
|
+
representWillFollow: isObligationOpenForTurn(wedgedTurn?.turnId ?? null),
|
|
540
|
+
silenceMs: ctx.silenceMs,
|
|
541
|
+
})
|
|
542
|
+
if (teardownNotice.send) {
|
|
543
|
+
try {
|
|
544
|
+
// LOUD (disable_notification: false). The user asked a question and is
|
|
545
|
+
// being told their attempt was killed — that is exactly the case where
|
|
546
|
+
// silence would train them to distrust the channel.
|
|
547
|
+
await sendSilenceText(fbChatId, ctx.threadId ?? null, teardownNotice.text, false)
|
|
548
|
+
} catch (err) {
|
|
549
|
+
process.stderr.write(
|
|
550
|
+
`silence-poke teardown notice sendMessage failed chat=${fbChatId} thread=${ctx.threadId}: ${err}\n`,
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
process.stderr.write(
|
|
555
|
+
`telegram gateway: silence-poke teardown notice skipped reason=${teardownNotice.reason} ` +
|
|
556
|
+
`chat=${fbChatId} thread=${ctx.threadId ?? '-'}\n`,
|
|
557
|
+
)
|
|
558
|
+
}
|
|
442
559
|
// Best-effort: clear any pending silent-end marker so the Stop hook
|
|
443
560
|
// doesn't double-block when claude eventually exits the wedged turn.
|
|
444
561
|
try {
|
|
@@ -464,7 +581,10 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
464
581
|
process.stderr.write(
|
|
465
582
|
`telegram gateway: silence-poke framework-fallback ended wedged turn ` +
|
|
466
583
|
`chat=${fbChatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs} ` +
|
|
467
|
-
|
|
584
|
+
// #3580 L3 — report the gate that actually ran, not the looser
|
|
585
|
+
// chat/thread match: `turnMatchesFallback` was true in cases where the
|
|
586
|
+
// keyed liveness check skipped the teardown, so the field lied.
|
|
587
|
+
`currentTurn_nulled=${tearsDownLiveTurn} ` +
|
|
468
588
|
`drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}` +
|
|
469
589
|
`${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}` +
|
|
470
590
|
`${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ''}\n`,
|
|
@@ -19,13 +19,24 @@
|
|
|
19
19
|
* record and edits the card closed.
|
|
20
20
|
*
|
|
21
21
|
* File format: a single JSON object `{ pending, delivered }`, written
|
|
22
|
-
* synchronously to avoid interleaving on concurrent auto-denies, mode 0o600
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* synchronously to avoid interleaving on concurrent auto-denies, mode 0o600,
|
|
23
|
+
* ATOMICALLY (tmp + fsync + rename) so a crash mid-persist can't leave a torn
|
|
24
|
+
* file. Mirrors the `permission-card-store.ts` pattern. Both lists are
|
|
25
|
+
* hard-capped so the file stays tiny under a runaway loop. A corrupt file is
|
|
26
|
+
* quarantined and logged loudly rather than silently read as "nothing was
|
|
27
|
+
* missed" — see store-file.ts. NOTE: atomic REPLACEMENT only —
|
|
28
|
+
* whole-old-or-whole-new, not power-loss durability; the missing
|
|
29
|
+
* parent-directory fsync is tracked in #3603.
|
|
25
30
|
*/
|
|
26
31
|
|
|
27
|
-
import {
|
|
32
|
+
import { unlinkSync } from 'node:fs'
|
|
28
33
|
import { join } from 'node:path'
|
|
34
|
+
import { atomicWriteFileSync } from '../../src/util/atomic.js'
|
|
35
|
+
import {
|
|
36
|
+
preserveUnreadableStoreFile,
|
|
37
|
+
quarantineCorruptStoreFile,
|
|
38
|
+
readStoreJsonSync,
|
|
39
|
+
} from './store-file.js'
|
|
29
40
|
|
|
30
41
|
export interface MissedApproval {
|
|
31
42
|
/** The permission request_id that timed out (dedup key). */
|
|
@@ -80,29 +91,67 @@ export interface MissedApprovalsStore {
|
|
|
80
91
|
export const MAX_PENDING = 50
|
|
81
92
|
export const MAX_DELIVERED = 20
|
|
82
93
|
|
|
83
|
-
export function createMissedApprovalsStore(
|
|
94
|
+
export function createMissedApprovalsStore(
|
|
95
|
+
stateDir: string,
|
|
96
|
+
/** Log sink — defaults to stderr (the gateway's runtime log). */
|
|
97
|
+
log: (line: string) => void = l => process.stderr.write(l),
|
|
98
|
+
): MissedApprovalsStore {
|
|
84
99
|
const filePath = join(stateDir, 'missed-approvals.json')
|
|
85
100
|
|
|
101
|
+
const EMPTY = (): FileShape => ({ pending: [], delivered: [] })
|
|
102
|
+
|
|
103
|
+
/** Set when the last read failed for a non-ENOENT reason — the next write
|
|
104
|
+
* must preserve the file it could not read instead of clobbering it. */
|
|
105
|
+
let unreadable = false
|
|
106
|
+
|
|
86
107
|
function read(): FileShape {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
108
|
+
const result = readStoreJsonSync(filePath, 'missed-approvals-store', log)
|
|
109
|
+
unreadable = result.status === 'unreadable'
|
|
110
|
+
if (result.status !== 'ok') return EMPTY()
|
|
111
|
+
const parsed = result.value as Partial<FileShape>
|
|
112
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
113
|
+
quarantineCorruptStoreFile(
|
|
114
|
+
filePath,
|
|
115
|
+
'missed-approvals-store',
|
|
116
|
+
'parsed to a non-object — not a missed-approvals file',
|
|
117
|
+
log,
|
|
118
|
+
)
|
|
119
|
+
return EMPTY()
|
|
120
|
+
}
|
|
121
|
+
// A PRESENT-but-non-array list is corruption, not "empty". Coercing it to
|
|
122
|
+
// [] would resurrect the exact bug this store was hardened against: a
|
|
123
|
+
// half-written `{"pending": null}` parses fine, so quarantine would never
|
|
124
|
+
// fire and the digest would silently come up empty. An ABSENT list is the
|
|
125
|
+
// legitimate cold-start/partial-shape case and stays silent.
|
|
126
|
+
for (const field of ['pending', 'delivered'] as const) {
|
|
127
|
+
if (parsed[field] !== undefined && !Array.isArray(parsed[field])) {
|
|
128
|
+
quarantineCorruptStoreFile(
|
|
129
|
+
filePath,
|
|
130
|
+
'missed-approvals-store',
|
|
131
|
+
`\`${field}\` is present but not an array — truncated or malformed write`,
|
|
132
|
+
log,
|
|
133
|
+
)
|
|
134
|
+
return EMPTY()
|
|
93
135
|
}
|
|
94
|
-
}
|
|
95
|
-
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
pending: parsed.pending ?? [],
|
|
139
|
+
delivered: parsed.delivered ?? [],
|
|
96
140
|
}
|
|
97
141
|
}
|
|
98
142
|
|
|
99
143
|
function write(f: FileShape): void {
|
|
100
144
|
try {
|
|
101
|
-
|
|
145
|
+
// Fail closed: never let an overwrite be what destroys state we merely
|
|
146
|
+
// failed to READ (flaky mount, transient EACCES).
|
|
147
|
+
if (unreadable) {
|
|
148
|
+
preserveUnreadableStoreFile(filePath, 'missed-approvals-store', log)
|
|
149
|
+
unreadable = false
|
|
150
|
+
}
|
|
151
|
+
// tmp + fsync + rename — never truncate the destination in place.
|
|
152
|
+
atomicWriteFileSync(filePath, JSON.stringify(f), 0o600)
|
|
102
153
|
} catch (err) {
|
|
103
|
-
|
|
104
|
-
`telegram gateway: missed-approvals-store write failed: ${(err as Error).message}\n`,
|
|
105
|
-
)
|
|
154
|
+
log(`telegram gateway: missed-approvals-store write failed: ${(err as Error).message}\n`)
|
|
106
155
|
}
|
|
107
156
|
}
|
|
108
157
|
|
|
@@ -242,6 +242,10 @@ export class ObligationLedger {
|
|
|
242
242
|
* since `lastRepresentedAt`. Without this, the 5s sweep can fire again before
|
|
243
243
|
* the re-presented turn even reaches the agent, burning the represent budget
|
|
244
244
|
* immediately and producing back-to-back escalations on the same message.
|
|
245
|
+
* #3550: this window is retired early once a turn that started after the
|
|
246
|
+
* re-present has ENDED — at that point the trailing-answer grace above is the
|
|
247
|
+
* one covering the real risk, and holding the rest of the represent window is
|
|
248
|
+
* pure latency. See `representGraceStillProtecting`.
|
|
245
249
|
*/
|
|
246
250
|
decideAtIdle(opts?: {
|
|
247
251
|
now: number
|
|
@@ -267,6 +271,80 @@ export class ObligationLedger {
|
|
|
267
271
|
return { action: 'represent', obligation: o }
|
|
268
272
|
}
|
|
269
273
|
|
|
274
|
+
/**
|
|
275
|
+
* #3550 — is the per-represent grace still doing real work for this
|
|
276
|
+
* obligation? Pure so the rule is testable in isolation.
|
|
277
|
+
*
|
|
278
|
+
* The per-represent grace exists for ONE reason, stated at its definition:
|
|
279
|
+
* "the 5s sweep can fire again before the re-presented turn even reaches the
|
|
280
|
+
* agent". It is a proxy for "the re-present has not landed yet". Once a turn
|
|
281
|
+
* that started AFTER the re-present has ENDED, the proxy is spent — the
|
|
282
|
+
* re-present demonstrably reached the agent, the agent ran a whole turn on
|
|
283
|
+
* it, and that turn is over. Holding the remaining ~110s of a 120s window
|
|
284
|
+
* open at that point delays the next rung of a ladder that is already
|
|
285
|
+
* bounded by `maxRepresents`, for no protective benefit.
|
|
286
|
+
*
|
|
287
|
+
* The obligation is NOT released into the void when this returns false: the
|
|
288
|
+
* trailing-answer grace (`trailingGraceMs` from `lastTurnEndedAt`, 45s by
|
|
289
|
+
* default) is a separate, independently-evaluated window that covers exactly
|
|
290
|
+
* the risk that matters here — a slow answer still landing after its turn
|
|
291
|
+
* ended. So the early-out only applies when that grace is actually armed
|
|
292
|
+
* (`trailingGraceMs > 0`); with it disabled, the represent grace stays the
|
|
293
|
+
* full window rather than leaving the obligation ungated.
|
|
294
|
+
*
|
|
295
|
+
* A re-present with NO ended turn after it — the genuinely in-flight case
|
|
296
|
+
* the grace was written for — is untouched and keeps the full window.
|
|
297
|
+
*
|
|
298
|
+
* FLOOR (`MIN_REPRESENT_INTERVAL_MS`). Retiring the represent window makes the
|
|
299
|
+
* rung interval DERIVED — `trailingGraceMs + the represent-turn's duration`
|
|
300
|
+
* — where it used to be floored by the flat 120s window regardless of how the
|
|
301
|
+
* trailing grace was tuned. Without a floor,
|
|
302
|
+
* `SWITCHROOM_OBLIGATION_ESCALATE_GRACE_MS=1000` (a plausible "make it
|
|
303
|
+
* snappier" tune) collapses the whole ladder to ~1s+d per rung and escalates
|
|
304
|
+
* to the operator within ~15-20s of the original message. That is a config
|
|
305
|
+
* footgun the old code could not have, so it is closed by a MECHANISM, not a
|
|
306
|
+
* doc warning: the early-out is withheld until at least
|
|
307
|
+
* `min(representGraceMs, max(trailingGraceMs, MIN_REPRESENT_INTERVAL_MS))`
|
|
308
|
+
* has elapsed since the re-present. At the defaults (45s trailing / 120s
|
|
309
|
+
* represent) the floor is 45s and never binds — the trailing grace, measured
|
|
310
|
+
* from the strictly-later turn end, always expires after it — so this changes
|
|
311
|
+
* nothing in the shipped configuration.
|
|
312
|
+
*/
|
|
313
|
+
static representGraceStillProtecting(
|
|
314
|
+
o: Pick<Obligation, 'lastRepresentedAt' | 'lastTurnEndedAt'>,
|
|
315
|
+
now: number,
|
|
316
|
+
representGraceMs: number,
|
|
317
|
+
trailingGraceMs: number,
|
|
318
|
+
): boolean {
|
|
319
|
+
if (representGraceMs <= 0) return false
|
|
320
|
+
if (o.lastRepresentedAt == null) return false
|
|
321
|
+
const sinceRepresent = now - o.lastRepresentedAt
|
|
322
|
+
if (sinceRepresent >= representGraceMs) return false
|
|
323
|
+
// Inside the window. Is it still protecting anything?
|
|
324
|
+
const representTurnEnded =
|
|
325
|
+
o.lastTurnEndedAt != null && o.lastTurnEndedAt > o.lastRepresentedAt
|
|
326
|
+
if (representTurnEnded && trailingGraceMs > 0) {
|
|
327
|
+
// Spent — the trailing grace takes over, but never sooner than the floor
|
|
328
|
+
// (and never longer than the represent window the operator configured).
|
|
329
|
+
const floorMs = Math.min(
|
|
330
|
+
representGraceMs,
|
|
331
|
+
Math.max(trailingGraceMs, ObligationLedger.MIN_REPRESENT_INTERVAL_MS),
|
|
332
|
+
)
|
|
333
|
+
return sinceRepresent < floorMs
|
|
334
|
+
}
|
|
335
|
+
return true
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The hard floor on the interval between two rungs of the represent ladder,
|
|
340
|
+
* used by `representGraceStillProtecting`. Independent of how the trailing
|
|
341
|
+
* grace is tuned, an obligation is never acted on again within this long of
|
|
342
|
+
* its own re-present. 30s comfortably exceeds the 5s sweep tick and the
|
|
343
|
+
* round-trip for a re-presented turn to reach the agent and answer, which is
|
|
344
|
+
* the only thing the represent window was ever debouncing.
|
|
345
|
+
*/
|
|
346
|
+
static readonly MIN_REPRESENT_INTERVAL_MS = 30_000
|
|
347
|
+
|
|
270
348
|
/** The oldest open obligation that is currently ELIGIBLE to act on — i.e. NOT
|
|
271
349
|
* within any grace window:
|
|
272
350
|
* - trailing-answer grace: its handling turn ended < `graceMs` ago (a queued
|
|
@@ -275,9 +353,11 @@ export class ObligationLedger {
|
|
|
275
353
|
* - background-work grace: when `backgroundWorkActive`, it was opened <
|
|
276
354
|
* `backgroundGraceMs` ago (genuine in-flight autonomous work — bounded by
|
|
277
355
|
* the ceiling so a stale/leaked worker can't suppress escalation forever);
|
|
278
|
-
* - per-represent grace: it was re-presented < `representGraceMs` ago
|
|
279
|
-
*
|
|
280
|
-
* the re-presented turn even reaches the
|
|
356
|
+
* - per-represent grace: it was re-presented < `representGraceMs` ago AND that
|
|
357
|
+
* re-present has not yet produced a completed turn (prevents a 5s sweep tick
|
|
358
|
+
* from immediately firing again before the re-presented turn even reaches the
|
|
359
|
+
* agent — see `representGraceStillProtecting` for why a turn that has already
|
|
360
|
+
* ended retires this window early, #3550). */
|
|
281
361
|
private oldestEligible(
|
|
282
362
|
now: number,
|
|
283
363
|
graceMs: number,
|
|
@@ -290,7 +370,7 @@ export class ObligationLedger {
|
|
|
290
370
|
if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs) continue // trailing-answer grace
|
|
291
371
|
if (backgroundWorkActive && backgroundGraceMs > 0 && now - o.openedAt < backgroundGraceMs)
|
|
292
372
|
continue // in-flight autonomous work, bounded by the ceiling
|
|
293
|
-
if (
|
|
373
|
+
if (ObligationLedger.representGraceStillProtecting(o, now, representGraceMs, graceMs))
|
|
294
374
|
continue // per-represent grace: sweep fired before re-presented turn landed
|
|
295
375
|
if (best === undefined || o.openedAt < best.openedAt) best = o
|
|
296
376
|
}
|