switchroom 0.18.31 → 0.18.33
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 +4 -2
- package/dist/auth-broker/index.js +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +6403 -13456
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +34 -4
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -26,6 +26,28 @@
|
|
|
26
26
|
|
|
27
27
|
import type { InboundMessage } from './ipc-protocol.js'
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* #3282 — the durable per-chunk snapshot of a PARTIALLY delivered backstop
|
|
31
|
+
* answer, attached to the obligation so the represent can resume the exact
|
|
32
|
+
* captured answer's non-`landed-confirmed` tail (byte-identical) instead of
|
|
33
|
+
* regenerating it (which re-posts chunks that already landed). Rides the
|
|
34
|
+
* obligation ledger's atomic snapshot, so it survives a gateway/container
|
|
35
|
+
* restart. See captured-answer-resume.ts for the resume mechanism.
|
|
36
|
+
*/
|
|
37
|
+
export interface CapturedDeliverySnapshot {
|
|
38
|
+
/** The byte-identical captured answer, split into chunks (the SAME split the
|
|
39
|
+
* original backstop delivery used). Re-delivered verbatim on resume. */
|
|
40
|
+
readonly chunks: string[]
|
|
41
|
+
/** Per-LANDED-chunk state: the landed message id(s) + whether a read-back
|
|
42
|
+
* confirmed the message exists. A chunk absent here never landed → the resume
|
|
43
|
+
* treats it as `unsent` and re-sends it. */
|
|
44
|
+
readonly chunkStates: Array<{
|
|
45
|
+
readonly index: number
|
|
46
|
+
readonly messageIds: number[]
|
|
47
|
+
readonly confirmed: boolean
|
|
48
|
+
}>
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
export interface Obligation {
|
|
30
52
|
/** deriveTurnId(chat, thread, messageId) — the stable identity. */
|
|
31
53
|
readonly originTurnId: string
|
|
@@ -61,6 +83,12 @@ export interface Obligation {
|
|
|
61
83
|
* re-present/escalate when the sweep fires < 5s later. Durable (part of the
|
|
62
84
|
* snapshot) so the grace window survives a restart. */
|
|
63
85
|
lastRepresentedAt?: number
|
|
86
|
+
/** #3282 — durable captured-answer snapshot for a PARTIALLY delivered backstop
|
|
87
|
+
* answer. Present ⇒ the represent branch drives a byte-identical captured-answer
|
|
88
|
+
* RESUME of the non-confirmed tail (never a regeneration); absent ⇒ the genuine
|
|
89
|
+
* "model wrote nothing" represent falls through to fresh generation. Dropped
|
|
90
|
+
* with the obligation on close/escalate (bounded lifetime, design A6). */
|
|
91
|
+
capturedDelivery?: CapturedDeliverySnapshot
|
|
64
92
|
}
|
|
65
93
|
|
|
66
94
|
/** What the gateway should do for the oldest open obligation at an idle boundary. */
|
|
@@ -316,6 +344,20 @@ export class ObligationLedger {
|
|
|
316
344
|
return null
|
|
317
345
|
}
|
|
318
346
|
|
|
347
|
+
/**
|
|
348
|
+
* #3282 — attach (or refresh) the durable captured-answer snapshot for a
|
|
349
|
+
* PARTIALLY delivered backstop answer. No-op if the obligation isn't open (a
|
|
350
|
+
* fully delivered turn closes its obligation, so there is nothing to resume).
|
|
351
|
+
* Persists, so the snapshot survives a restart and the represent resumes the
|
|
352
|
+
* missing tail instead of regenerating the whole answer.
|
|
353
|
+
*/
|
|
354
|
+
noteCapturedDelivery(originTurnId: string, snapshot: CapturedDeliverySnapshot): void {
|
|
355
|
+
const o = this.open.get(originTurnId)
|
|
356
|
+
if (o === undefined || snapshot.chunks.length === 0) return
|
|
357
|
+
o.capturedDelivery = snapshot
|
|
358
|
+
this.persist()
|
|
359
|
+
}
|
|
360
|
+
|
|
319
361
|
/** Record that an obligation was just re-presented (bumps representCount, stamps
|
|
320
362
|
* lastRepresentedAt for the per-represent grace window). */
|
|
321
363
|
markRepresented(originTurnId: string, now = Date.now()): number {
|
|
@@ -55,6 +55,42 @@ function isObligationRow(x: unknown): x is Obligation {
|
|
|
55
55
|
)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* #3282 — true iff the optional captured-answer snapshot is structurally sound.
|
|
60
|
+
* A malformed blob (partial write, forward-incompatible shape) must NOT crash the
|
|
61
|
+
* resume: `sanitizeCapturedDelivery` strips it, so the obligation degrades to a
|
|
62
|
+
* fresh-generation represent (today's behaviour) rather than throwing.
|
|
63
|
+
*/
|
|
64
|
+
function isValidCapturedDelivery(x: unknown): boolean {
|
|
65
|
+
if (x == null || typeof x !== 'object') return false
|
|
66
|
+
const c = x as Record<string, unknown>
|
|
67
|
+
// Empty chunks ⇒ nothing to resume: treat as malformed so it is STRIPPED and
|
|
68
|
+
// the obligation degrades to a (bounded) fresh-generation represent rather than
|
|
69
|
+
// sitting non-null-but-empty and no-op'ing the sweep forever.
|
|
70
|
+
if (!Array.isArray(c.chunks) || c.chunks.length === 0 || !c.chunks.every((t) => typeof t === 'string')) return false
|
|
71
|
+
if (!Array.isArray(c.chunkStates)) return false
|
|
72
|
+
return c.chunkStates.every((s) => {
|
|
73
|
+
if (s == null || typeof s !== 'object') return false
|
|
74
|
+
const st = s as Record<string, unknown>
|
|
75
|
+
return (
|
|
76
|
+
typeof st.index === 'number' &&
|
|
77
|
+
typeof st.confirmed === 'boolean' &&
|
|
78
|
+
Array.isArray(st.messageIds) &&
|
|
79
|
+
st.messageIds.every((m) => typeof m === 'number')
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Drop a malformed `capturedDelivery` in place so a corrupt snapshot degrades to
|
|
85
|
+
* fresh-generation represent instead of crashing the resume (fail-open). */
|
|
86
|
+
function sanitizeCapturedDelivery(o: Obligation): Obligation {
|
|
87
|
+
if (o.capturedDelivery != null && !isValidCapturedDelivery(o.capturedDelivery)) {
|
|
88
|
+
const { capturedDelivery: _drop, ...rest } = o
|
|
89
|
+
return rest
|
|
90
|
+
}
|
|
91
|
+
return o
|
|
92
|
+
}
|
|
93
|
+
|
|
58
94
|
/**
|
|
59
95
|
* Load the persisted open set. Returns [] on a missing, unreadable, or
|
|
60
96
|
* malformed file (fail-open to empty: a corrupt snapshot must never crash boot;
|
|
@@ -78,7 +114,7 @@ export function loadObligations(path: string, fs: ObligationStoreFsSeam): Obliga
|
|
|
78
114
|
if (parsed == null || typeof parsed !== 'object') return []
|
|
79
115
|
const env = parsed as Record<string, unknown>
|
|
80
116
|
if (env.v !== 1 || !Array.isArray(env.obligations)) return []
|
|
81
|
-
return env.obligations.filter(isObligationRow)
|
|
117
|
+
return env.obligations.filter(isObligationRow).map(sanitizeCapturedDelivery)
|
|
82
118
|
}
|
|
83
119
|
|
|
84
120
|
/**
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* obligation-wiring.ts — obligation open/close/cancel + the idle sweep
|
|
3
|
+
* (#2996 P8 PR-C2).
|
|
4
|
+
*
|
|
5
|
+
* Extracted VERBATIM from gateway.ts: `closeObligationOnSubstantiveReply`,
|
|
6
|
+
* `openObligationFromInbound`, `cancelInterruptedObligation` and
|
|
7
|
+
* `obligationSweep` (idle-only re-present / escalate driver). STATE stays in
|
|
8
|
+
* gateway.ts — the `ObligationLedger` instance + durable store,
|
|
9
|
+
* `pendingCrossTurnGate`, `obligationEscalateInFlight`, the grace consts, and
|
|
10
|
+
* the escalation NUDGE SEND (the one bot-api touch, kept behind the gateway's
|
|
11
|
+
* retry-policy allowlist and injected as `sendEscalationNudge`). The sweep
|
|
12
|
+
* `setInterval` stays gateway-owned (P0c: modules export the tick body).
|
|
13
|
+
*
|
|
14
|
+
* Never-storm invariants preserved by construction: grace consts injected
|
|
15
|
+
* verbatim; `obligationEscalateInFlight` crosses as the SAME shared Set
|
|
16
|
+
* reference; the represent/escalate guards are the same imported pure modules.
|
|
17
|
+
*/
|
|
18
|
+
import type { CurrentTurn, ObligationWiringDeps } from './gateway.js'
|
|
19
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
20
|
+
import { shouldTrackDelivery } from './inbound-delivery-confirm.js'
|
|
21
|
+
import { deriveTurnId } from './derive-turn-id.js'
|
|
22
|
+
import { buildObligationRepresentInbound, type Obligation } from './obligation-ledger.js'
|
|
23
|
+
import { driveEscalation } from './escalation-drive.js'
|
|
24
|
+
import { shouldSuppressRepresent } from './represent-guard.js'
|
|
25
|
+
import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
|
|
26
|
+
|
|
27
|
+
export function createObligationWiring(deps: ObligationWiringDeps) {
|
|
28
|
+
const {
|
|
29
|
+
OBLIGATION_LEDGER_ENABLED,
|
|
30
|
+
HISTORY_ENABLED,
|
|
31
|
+
OBLIGATION_BACKGROUND_WORK_GRACE_MS,
|
|
32
|
+
OBLIGATION_ESCALATE_GRACE_MS,
|
|
33
|
+
OBLIGATION_REPRESENT_GRACE_MS,
|
|
34
|
+
OBLIGATION_REPRESENT_MAX,
|
|
35
|
+
OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS,
|
|
36
|
+
OBLIGATION_ESCALATE_MAX,
|
|
37
|
+
OBLIGATION_ESCALATE_SEND_DEADLINE_MS,
|
|
38
|
+
obligationLedger,
|
|
39
|
+
obligationEscalateInFlight,
|
|
40
|
+
pendingCrossTurnGate,
|
|
41
|
+
pendingInboundBuffer,
|
|
42
|
+
capturedResume,
|
|
43
|
+
getCurrentTurn,
|
|
44
|
+
turnInFlightForGate,
|
|
45
|
+
agentHasInFlightBackgroundWork,
|
|
46
|
+
hasOutboundDeliveredSince,
|
|
47
|
+
findTurnByOriginId,
|
|
48
|
+
bridgeAlive,
|
|
49
|
+
sendEscalationNudge,
|
|
50
|
+
} = deps
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
|
|
54
|
+
* (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
|
|
55
|
+
* obligation discharged is the one for the SAME origin the answer routes to
|
|
56
|
+
* (origin_turn_id the model echoed, else the routed origin the gateway resolved,
|
|
57
|
+
* else the live turn). So 713's reply closes 713's obligation even after
|
|
58
|
+
* currentTurn flipped to 715, and 715 stays open until ITS own substantive
|
|
59
|
+
* answer. Answers to re-presented obligations (via=quoted, no model echo) close
|
|
60
|
+
* via the gateway-resolved routedOriginTurn. An ack does NOT close (so
|
|
61
|
+
* ack-then-ghost is re-presented, not re-dropped). The live-turn fallback fires
|
|
62
|
+
* only for the live turn's OWN obligation (it was the turn delivering this
|
|
63
|
+
* reply), preserving the 713/715 invariant. No-op unless the flag is on.
|
|
64
|
+
*
|
|
65
|
+
* @param routedOriginTurn — the origin the reply router already resolved
|
|
66
|
+
* (echoedTurn ?? quotedTurn); pass whenever the TURN_ORIGIN_ROUTING path ran.
|
|
67
|
+
* Skipped when null/undefined (pre-routing paths, or DM with no quote).
|
|
68
|
+
*/
|
|
69
|
+
function closeObligationOnSubstantiveReply(
|
|
70
|
+
args: Record<string, unknown>,
|
|
71
|
+
liveTurn: CurrentTurn | null | undefined,
|
|
72
|
+
routedOriginTurn?: CurrentTurn | null,
|
|
73
|
+
): void {
|
|
74
|
+
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
75
|
+
const echoed = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
76
|
+
// routedOriginTurn is the gateway-resolved origin (echoedTurn ?? quotedTurn).
|
|
77
|
+
// Only pass it as routedOriginId when it DIFFERS from the echoed turn (if
|
|
78
|
+
// echoed is present, resolveCloseTarget's first branch already handles it),
|
|
79
|
+
// and only when it is NOT the live turn (live-turn is the fallback, not the
|
|
80
|
+
// routed origin — passing live turn here would bypass the live-turn fallback
|
|
81
|
+
// logic and still close correctly, but naming matters for the 713/715 case:
|
|
82
|
+
// the routed origin on a via=quoted reply IS the origin, not "live fallback").
|
|
83
|
+
const routedOriginId =
|
|
84
|
+
routedOriginTurn != null && echoed == null
|
|
85
|
+
? routedOriginTurn.turnId
|
|
86
|
+
: null
|
|
87
|
+
const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId, routedOriginId)
|
|
88
|
+
if (target != null) obligationLedger.close(target)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* PR2 obligation-ledger OPEN. Track a fresh user inbound as an unanswered
|
|
93
|
+
* obligation the moment it is received — called BEFORE the buffer-until-idle /
|
|
94
|
+
* deliver split so a mid-turn cross-topic inbound (the 715 case: buffered while
|
|
95
|
+
* another turn runs) is tracked too. Drain paths re-deliver buffered inbounds
|
|
96
|
+
* via sendToAgent (NOT through handleInbound), so handleInbound is the ONLY
|
|
97
|
+
* point that sees every fresh inbound — opening here is mandatory for both
|
|
98
|
+
* branches. Same gate as delivery-tracking (real user turns only; synthetic /
|
|
99
|
+
* steering / `!` interrupt / empty excluded — they have no origin id / need no
|
|
100
|
+
* answer; deriveTurnId null-guards them). Idempotent → opening at buffer-time
|
|
101
|
+
* and any later delivery is safe. No-op unless the flag is on.
|
|
102
|
+
*/
|
|
103
|
+
function openObligationFromInbound(
|
|
104
|
+
inboundMsg: InboundMessage,
|
|
105
|
+
gate: { isSteering: boolean; isInterrupt: boolean; effectiveText: string },
|
|
106
|
+
): void {
|
|
107
|
+
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
108
|
+
if (
|
|
109
|
+
!shouldTrackDelivery({
|
|
110
|
+
isSteering: gate.isSteering,
|
|
111
|
+
isInterrupt: gate.isInterrupt,
|
|
112
|
+
hasSource: inboundMsg.meta?.source != null,
|
|
113
|
+
effectiveText: gate.effectiveText,
|
|
114
|
+
})
|
|
115
|
+
) {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
const oid = deriveTurnId(inboundMsg.chatId, inboundMsg.threadId, inboundMsg.messageId)
|
|
119
|
+
if (oid == null) return
|
|
120
|
+
obligationLedger.openIfAbsent({
|
|
121
|
+
originTurnId: oid,
|
|
122
|
+
chatId: inboundMsg.chatId,
|
|
123
|
+
threadId: inboundMsg.threadId,
|
|
124
|
+
messageId: inboundMsg.messageId,
|
|
125
|
+
text: inboundMsg.text ?? '',
|
|
126
|
+
openedAt: Date.now(),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* An `!` interrupt SIGINT-kills the in-flight turn. That turn was handling a
|
|
132
|
+
* user message with an open obligation, and the killed turn does NOT reliably
|
|
133
|
+
* emit turn_end (so endCurrentTurnAtomic never closes it) — so without this the
|
|
134
|
+
* obligation survives and the idle sweep later re-presents/escalates "you have
|
|
135
|
+
* an earlier message you never answered" for a question the user EXPLICITLY
|
|
136
|
+
* cancelled. An interrupt is a deliberate redirect, so closing that obligation
|
|
137
|
+
* is the correct terminal (the user chose to interrupt; they can re-ask). Only
|
|
138
|
+
* the interrupted turn's OWN obligation is closed — queued siblings (other open
|
|
139
|
+
* obligations) are untouched. No-op when the flag is off, no turn is in flight,
|
|
140
|
+
* or the turn isn't a tracked obligation (synthetic / already closed).
|
|
141
|
+
*/
|
|
142
|
+
function cancelInterruptedObligation(): void {
|
|
143
|
+
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
144
|
+
const turn = getCurrentTurn()
|
|
145
|
+
if (turn == null) return
|
|
146
|
+
if (obligationLedger.close(turn.turnId)) {
|
|
147
|
+
process.stderr.write(
|
|
148
|
+
`telegram gateway: obligation cancelled by interrupt origin=${turn.turnId}\n`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
// Throttle for the background-work defer diagnostic (the 5s sweep would otherwise
|
|
155
|
+
// log every tick across a multi-minute research window).
|
|
156
|
+
let lastBgWorkDeferLogMs = 0
|
|
157
|
+
|
|
158
|
+
function obligationSweep(): void {
|
|
159
|
+
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
160
|
+
if (!obligationLedger.hasOpen()) return
|
|
161
|
+
if (turnInFlightForGate()) return // a turn is running — let it finish/answer
|
|
162
|
+
const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
163
|
+
const now = Date.now()
|
|
164
|
+
// Background-work grace: while genuine autonomous sub-agent work is in flight
|
|
165
|
+
// (a running worker, or an orphaned foreground sub-agent — neither visible to
|
|
166
|
+
// the turn machine), an obligation younger than the ceiling is NOT re-presented
|
|
167
|
+
// /escalated. Bounded by OBLIGATION_BACKGROUND_WORK_GRACE_MS so escalation
|
|
168
|
+
// always eventually fires. =0 disables it.
|
|
169
|
+
const backgroundWorkActive =
|
|
170
|
+
OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now)
|
|
171
|
+
// Grace window: skip an obligation whose handling turn ended < grace ago — its
|
|
172
|
+
// trailing slow/worker answer may still be landing (over-escalation fix).
|
|
173
|
+
// Per-represent grace: skip an obligation re-presented < grace ago — prevents
|
|
174
|
+
// the 5s sweep from immediately firing again before the re-present even lands.
|
|
175
|
+
const decision = obligationLedger.decideAtIdle(
|
|
176
|
+
OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive || OBLIGATION_REPRESENT_GRACE_MS > 0
|
|
177
|
+
? {
|
|
178
|
+
now,
|
|
179
|
+
graceMs: OBLIGATION_ESCALATE_GRACE_MS,
|
|
180
|
+
backgroundWorkActive,
|
|
181
|
+
backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS,
|
|
182
|
+
representGraceMs: OBLIGATION_REPRESENT_GRACE_MS,
|
|
183
|
+
}
|
|
184
|
+
: undefined,
|
|
185
|
+
)
|
|
186
|
+
const o = decision.obligation
|
|
187
|
+
if (decision.action === 'none' || o == null) {
|
|
188
|
+
if (backgroundWorkActive && obligationLedger.hasOpen() && now - lastBgWorkDeferLogMs > 60_000) {
|
|
189
|
+
lastBgWorkDeferLogMs = now
|
|
190
|
+
process.stderr.write(
|
|
191
|
+
`telegram gateway: obligation sweep deferred — in-flight autonomous sub-agent work ` +
|
|
192
|
+
`(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60_000)}m from receipt)\n`,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
if (decision.action === 'represent') {
|
|
198
|
+
// Fix #2472 — duplicate-represent guard. Before re-presenting AGAIN, check
|
|
199
|
+
// whether the agent has ALREADY delivered a substantive outbound reply to
|
|
200
|
+
// this chat SINCE the obligation was most recently re-presented. If so the
|
|
201
|
+
// obligation is satisfied-but-misdetected (the reply landed but its routing
|
|
202
|
+
// didn't resolve back to this origin, so the normal close path missed it) —
|
|
203
|
+
// close silently and do NOT re-fire, which is what produced the near-identical
|
|
204
|
+
// duplicate in #2472 (reply 10608 answered represent_count=1, yet
|
|
205
|
+
// represent_count=2 fired anyway → duplicate 10609).
|
|
206
|
+
//
|
|
207
|
+
// The cutoff is `lastRepresentedAt` (the time of the PREVIOUS represent), NOT
|
|
208
|
+
// `openedAt`. This is load-bearing: the genuine "agent wrote a plain-text
|
|
209
|
+
// answer and never called reply" case must still represent ONCE. On the first
|
|
210
|
+
// represent `lastRepresentedAt` is undefined, so this guard is a no-op and the
|
|
211
|
+
// single represent fires as before. Only the SECOND-and-later represent is
|
|
212
|
+
// gated — exactly where a reply that landed between fires must suppress the
|
|
213
|
+
// re-ask. Falls back to false (never suppresses) if history is unavailable.
|
|
214
|
+
if (
|
|
215
|
+
shouldSuppressRepresent(o, {
|
|
216
|
+
historyEnabled: HISTORY_ENABLED,
|
|
217
|
+
// Pass the represent-guard's OWN low threshold — a terse-but-real reply
|
|
218
|
+
// must suppress the duplicate (#2472/#2474), unlike the escalate branch
|
|
219
|
+
// below which keeps the 200-char default.
|
|
220
|
+
hasOutboundDeliveredSince: (chatId, sinceMs, threadId) =>
|
|
221
|
+
hasOutboundDeliveredSince(
|
|
222
|
+
chatId,
|
|
223
|
+
sinceMs,
|
|
224
|
+
threadId,
|
|
225
|
+
OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS,
|
|
226
|
+
),
|
|
227
|
+
})
|
|
228
|
+
) {
|
|
229
|
+
process.stderr.write(
|
|
230
|
+
`telegram gateway: obligation closed silently — reply delivered since last represent (no re-fire) origin=${o.originTurnId}\n`,
|
|
231
|
+
)
|
|
232
|
+
obligationLedger.close(o.originTurnId)
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
// #3282 — source-aware represent: a captured snapshot ⇒ resume the non-confirmed tail byte-identically; no snapshot ⇒ the fresh-generation represent below.
|
|
236
|
+
if (o.capturedDelivery != null) { capturedResume.dispatch(o); return }
|
|
237
|
+
// Re-present goes through the bridge → buffer. Only the represent path is
|
|
238
|
+
// gated on an empty buffer (let the existing drain run first, avoid
|
|
239
|
+
// double-presenting). Escalation below is NOT gated on the buffer — it is a
|
|
240
|
+
// direct Telegram send, independent of the bridge, so a represent stranded
|
|
241
|
+
// behind a dead bridge can never block the operator nudge.
|
|
242
|
+
if (pendingInboundBuffer.depth(agent) > 0) return
|
|
243
|
+
pendingInboundBuffer.push(agent, buildObligationRepresentInbound(o, Date.now()))
|
|
244
|
+
// PR1 (cross-turn stale-card guard, §9 lever 4 / race C/D). Arm the
|
|
245
|
+
// card-OPEN gate for the synthetic turn this represent inbound will spawn:
|
|
246
|
+
// carry the obligation's `openedAt` so that turn's first card-OPEN can ask
|
|
247
|
+
// "was a substantive answer already delivered since the obligation was
|
|
248
|
+
// raised?" and, if so, suppress the card (it would otherwise narrate beneath
|
|
249
|
+
// the answer the user already received). Keyed on the obligation's
|
|
250
|
+
// `originTurnId` — the SAME id the represent inbound carries
|
|
251
|
+
// (`buildObligationRepresentInbound` reuses `o.messageId`/`o.chatId`/
|
|
252
|
+
// `o.threadId`, so the enqueue-time `deriveTurnId` reconstructs exactly
|
|
253
|
+
// `o.originTurnId`). Keying on the turn id (not chat/thread) means ONLY the
|
|
254
|
+
// exact represent turn this gate was armed for can consume it; an unrelated
|
|
255
|
+
// later foreground turn on the same chat/thread has a different originTurnId
|
|
256
|
+
// → finds no entry → its card opens normally. This closes the residual
|
|
257
|
+
// cross-contamination window where a never-enqueued represent's stale gate
|
|
258
|
+
// could suppress an unrelated turn's card (the represent/duplicate-reply
|
|
259
|
+
// family). This does NOT gate the represent SEND — the represent guard above
|
|
260
|
+
// already owns suppressing an already-satisfied represent; this only governs
|
|
261
|
+
// the decorative card.
|
|
262
|
+
pendingCrossTurnGate.set(o.originTurnId, { sinceMs: o.openedAt })
|
|
263
|
+
const attempt = obligationLedger.markRepresented(o.originTurnId)
|
|
264
|
+
process.stderr.write(
|
|
265
|
+
`telegram gateway: obligation re-presented origin=${o.originTurnId} attempt=${attempt}/${OBLIGATION_REPRESENT_MAX}\n`,
|
|
266
|
+
)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
// escalate — re-present ladder exhausted. Before sending the user-visible
|
|
270
|
+
// apology, check whether the agent has ALREADY delivered an outbound reply
|
|
271
|
+
// to this chat since the obligation was opened. If yes, the obligation is
|
|
272
|
+
// stale (the agent did answer, just without closing the obligation via the
|
|
273
|
+
// normal close path) — close silently instead of alarming the user with a
|
|
274
|
+
// false "I may have missed this". This is Fix 4: escalate only on knowledge,
|
|
275
|
+
// not doubt. Fall back to false (safe: never suppresses) if history unavailable.
|
|
276
|
+
//
|
|
277
|
+
// #2788 Gap A — bridge-flap gate. The escalate branch DIRECT-SENDS (via
|
|
278
|
+
// bot.api.sendMessage below), bypassing the bridge. So while the bridge is
|
|
279
|
+
// down the represent branch is naturally stranded (bridge → buffer) but this
|
|
280
|
+
// branch would still fire a false "I may have missed this", even though the
|
|
281
|
+
// real reply is merely queued behind a transient outage. Defer: if the bridge
|
|
282
|
+
// is not alive, leave the obligation OPEN and re-drive on a later sweep once
|
|
283
|
+
// it recovers. Obligations survive bridge death (durable ledger, re-evaluated
|
|
284
|
+
// every sweep), so this deferral adds NO unbounded liveness dependency — the
|
|
285
|
+
// whole gateway already rests on the bridge eventually reconnecting.
|
|
286
|
+
if (shouldDeferEscalationForBridge({ bridgeAlive: bridgeAlive(agent) })) {
|
|
287
|
+
process.stderr.write(
|
|
288
|
+
`telegram gateway: obligation escalation deferred — bridge down (nudge waits for reconnect) origin=${o.originTurnId}\n`,
|
|
289
|
+
)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
if (HISTORY_ENABLED && hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)) {
|
|
293
|
+
process.stderr.write(
|
|
294
|
+
`telegram gateway: obligation closed silently — outbound delivered since open origin=${o.originTurnId}\n`,
|
|
295
|
+
)
|
|
296
|
+
obligationLedger.close(o.originTurnId)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
// Proceed with escalation: send ONE operator-visible nudge and close the
|
|
300
|
+
// obligation ONLY AFTER it actually lands. This inverts the old
|
|
301
|
+
// close-before-send (which silently dropped the terminal whenever the send
|
|
302
|
+
// failed): the close is now itself an observable terminal. A transient send
|
|
303
|
+
// failure leaves the obligation OPEN → retried next sweep; a PERMANENT one
|
|
304
|
+
// (dead topic even after thread-fallback, blocked bot) is bounded by
|
|
305
|
+
// OBLIGATION_ESCALATE_MAX → close best-effort (the user is unreachable, so a
|
|
306
|
+
// bounded give-up beats an infinite loop / a boot-surviving poison record).
|
|
307
|
+
// Drive one escalation attempt. The send is a direct Telegram nudge
|
|
308
|
+
// (retryWithThreadFallback: a stale/renumbered topic → THREAD_NOT_FOUND retries
|
|
309
|
+
// thread-less, the #2096 pattern). driveEscalation guards against concurrent
|
|
310
|
+
// sends, bounds the send with withDeadline (so a hung send can't leak the
|
|
311
|
+
// in-flight flag and wedge the obligation OPEN), closes only after a successful
|
|
312
|
+
// send, and bounds permanent failures to a best-effort close. Extracted so the
|
|
313
|
+
// hang → bounded → terminal path is executable in escalation-drive.test.ts —
|
|
314
|
+
// the path neither mtcute (can't hang Telegram) nor a synchronous test reaches.
|
|
315
|
+
void driveEscalation({
|
|
316
|
+
escId: o.originTurnId,
|
|
317
|
+
inFlight: obligationEscalateInFlight,
|
|
318
|
+
ledger: obligationLedger,
|
|
319
|
+
// The nudge send stays in gateway.ts (it owns the bot handle + the
|
|
320
|
+
// bot-api retry-policy allowlist); this module only decides WHEN it fires.
|
|
321
|
+
send: () => sendEscalationNudge(o),
|
|
322
|
+
maxAttempts: OBLIGATION_ESCALATE_MAX,
|
|
323
|
+
deadlineMs: OBLIGATION_ESCALATE_SEND_DEADLINE_MS,
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
closeObligationOnSubstantiveReply,
|
|
329
|
+
openObligationFromInbound,
|
|
330
|
+
cancelInterruptedObligation,
|
|
331
|
+
obligationSweep,
|
|
332
|
+
}
|
|
333
|
+
}
|